| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | |
| 11 | |
| 12 | |
| 13 | use std::path::PathBuf; |
| 14 | |
| 15 | use forge_core::issue_refs::{find_closing_refs, find_issue_refs}; |
| 16 | use forge_core::{IssueState, Repo, StateReason}; |
| 17 | use forge_git::history; |
| 18 | use serde_json::json; |
| 19 | |
| 20 | use crate::internal::is_zero_oid; |
| 21 | use crate::notify::{self, Activity}; |
| 22 | use crate::pulls_sync; |
| 23 | use crate::state::AppState; |
| 24 | use crate::webhooks::emit::{Sender, emit_item_action, emit_ref_update, lookup_sender}; |
| 25 | |
| 26 | |
| 27 | const MAX_PUSH_COMMITS: usize = 500; |
| 28 | |
| 29 | const MAX_NEW_BRANCH_COMMITS: usize = 50; |
| 30 | |
| 31 | |
| 32 | |
| 33 | |
| 34 | pub async fn after_ref_update( |
| 35 | state: &AppState, |
| 36 | repo: &Repo, |
| 37 | actor_id: &str, |
| 38 | refname: &str, |
| 39 | old: &str, |
| 40 | new: &str, |
| 41 | ) -> forge_core::Result<()> { |
| 42 | let sender = lookup_sender(state, actor_id).await; |
| 43 | emit_ref_update(state, repo, &sender, refname, old, new).await; |
| 44 | crate::ci::on_ref_update(state, repo, actor_id, refname, new).await; |
| 45 | if (refname.starts_with("refs/heads/") || refname.starts_with("refs/tags/")) |
| 46 | && let Err(e) = crate::mirrors::enqueue_push_mirrors(state, &repo.id).await |
| 47 | { |
| 48 | tracing::error!(error = %e, repo_id = %repo.id, "mirrors: could not queue push mirrors"); |
| 49 | } |
| 50 | if refname == format!("refs/heads/{}", repo.default_branch) && !is_zero_oid(new) { |
| 51 | crate::advisories::scan::spawn_repo_scan(state, repo); |
| 52 | } |
| 53 | crate::replication::enqueue_repo(state, &repo.id); |
| 54 | if refname == format!("refs/heads/{}", repo.default_branch) && !is_zero_oid(new) { |
| 55 | match forge_git::repo::repo_path(&state.git.data_dir, &repo.id) { |
| 56 | Ok(repo_dir) => { |
| 57 | tokio::spawn(close_from_pushed_commits( |
| 58 | state.clone(), |
| 59 | repo.clone(), |
| 60 | sender, |
| 61 | repo_dir, |
| 62 | actor_id.to_owned(), |
| 63 | old.to_owned(), |
| 64 | new.to_owned(), |
| 65 | )); |
| 66 | } |
| 67 | Err(e) => { |
| 68 | tracing::error!(error = %e, repo_id = %repo.id, "issue refs: bad repo path"); |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | pulls_sync::sync_pulls_for_ref(state, repo, actor_id, refname, old, new).await |
| 73 | } |
| 74 | |
| 75 | |
| 76 | async fn close_from_pushed_commits( |
| 77 | state: AppState, |
| 78 | repo: Repo, |
| 79 | sender: Sender, |
| 80 | repo_dir: PathBuf, |
| 81 | actor_id: String, |
| 82 | old: String, |
| 83 | new: String, |
| 84 | ) { |
| 85 | let old = (!is_zero_oid(&old)).then_some(old.as_str()); |
| 86 | let max = if old.is_some() { |
| 87 | MAX_PUSH_COMMITS |
| 88 | } else { |
| 89 | MAX_NEW_BRANCH_COMMITS |
| 90 | }; |
| 91 | let commits = match history::new_commits(&state.git, &repo_dir, old, &new, max).await { |
| 92 | Ok(commits) => commits, |
| 93 | Err(e) => { |
| 94 | tracing::error!(error = %e, repo_id = %repo.id, "issue refs: listing commits failed"); |
| 95 | return; |
| 96 | } |
| 97 | }; |
| 98 | let actor = (!actor_id.is_empty()).then_some(actor_id.as_str()); |
| 99 | for commit in &commits { |
| 100 | let text = format!("{}\n{}", commit.summary, commit.message); |
| 101 | if let Err(e) = apply_commit_refs(&state, &repo, &sender, actor, &commit.oid, &text).await { |
| 102 | tracing::error!(error = %e, repo_id = %repo.id, commit = %commit.oid, "issue refs: applying commit failed"); |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | |
| 108 | |
| 109 | async fn apply_commit_refs( |
| 110 | state: &AppState, |
| 111 | repo: &Repo, |
| 112 | sender: &Sender, |
| 113 | actor: Option<&str>, |
| 114 | oid: &str, |
| 115 | text: &str, |
| 116 | ) -> forge_core::Result<()> { |
| 117 | let store = &state.store; |
| 118 | let closing = find_closing_refs(text); |
| 119 | for number in find_issue_refs(text) { |
| 120 | let Some(issue) = store.issue_by_number(&repo.id, number).await? else { |
| 121 | continue; |
| 122 | }; |
| 123 | if closing.contains(&number) { |
| 124 | if issue.state == IssueState::Open { |
| 125 | let closed = store |
| 126 | .set_issue_state( |
| 127 | &issue.id, |
| 128 | actor, |
| 129 | IssueState::Closed, |
| 130 | Some(StateReason::Completed), |
| 131 | json!({"commit": oid}), |
| 132 | ) |
| 133 | .await?; |
| 134 | if closed { |
| 135 | emit_item_action(state, repo, sender, false, number, "closed").await; |
| 136 | notify::dispatch( |
| 137 | state, |
| 138 | repo, |
| 139 | &issue, |
| 140 | actor.unwrap_or(""), |
| 141 | Activity::Closed, |
| 142 | None, |
| 143 | ) |
| 144 | .await; |
| 145 | } |
| 146 | } |
| 147 | continue; |
| 148 | } |
| 149 | let already = store.list_events(&issue.id).await?.iter().any(|e| { |
| 150 | e.kind == "referenced" && e.data.get("commit").and_then(|c| c.as_str()) == Some(oid) |
| 151 | }); |
| 152 | if !already { |
| 153 | store |
| 154 | .add_event(&issue.id, actor, "referenced", json!({"commit": oid})) |
| 155 | .await?; |
| 156 | } |
| 157 | } |
| 158 | Ok(()) |
| 159 | } |
| 160 | |
| 161 | #[cfg(test)] |
| 162 | mod tests { |
| 163 | use std::path::{Path, PathBuf}; |
| 164 | |
| 165 | use forge_core::Visibility; |
| 166 | use forge_core::webhooks::{Delivery, WebhookEvent}; |
| 167 | use forge_git::repo::{init_bare, repo_path}; |
| 168 | use serde_json::Value; |
| 169 | use tempfile::TempDir; |
| 170 | |
| 171 | use super::*; |
| 172 | use crate::test_support::{KEY, test_state}; |
| 173 | |
| 174 | const ZERO: &str = "0000000000000000000000000000000000000000"; |
| 175 | |
| 176 | fn git(dir: &Path, args: &[&str]) -> String { |
| 177 | let out = std::process::Command::new("git") |
| 178 | .arg("-C") |
| 179 | .arg(dir) |
| 180 | .args(args) |
| 181 | .env("GIT_AUTHOR_NAME", "test") |
| 182 | .env("GIT_AUTHOR_EMAIL", "test@example.com") |
| 183 | .env("GIT_COMMITTER_NAME", "test") |
| 184 | .env("GIT_COMMITTER_EMAIL", "test@example.com") |
| 185 | .output() |
| 186 | .expect("git runs"); |
| 187 | assert!( |
| 188 | out.status.success(), |
| 189 | "git {args:?} failed: {}", |
| 190 | String::from_utf8_lossy(&out.stderr) |
| 191 | ); |
| 192 | String::from_utf8_lossy(&out.stdout).trim().to_string() |
| 193 | } |
| 194 | |
| 195 | fn commit(work: &Path, file: &str, contents: &str) -> String { |
| 196 | std::fs::write(work.join(file), contents).expect("write file"); |
| 197 | git(work, &["add", file]); |
| 198 | git(work, &["commit", "-m", contents]); |
| 199 | git(work, &["rev-parse", "HEAD"]) |
| 200 | } |
| 201 | |
| 202 | |
| 203 | struct Fixture { |
| 204 | _tmp: TempDir, |
| 205 | state: AppState, |
| 206 | repo: Repo, |
| 207 | owner_id: String, |
| 208 | hook_id: String, |
| 209 | bare: PathBuf, |
| 210 | work: PathBuf, |
| 211 | } |
| 212 | |
| 213 | async fn fixture() -> Fixture { |
| 214 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 215 | let state = test_state(tmp.path()).await; |
| 216 | let owner = state |
| 217 | .store |
| 218 | .create_user("alice", None, "unused", false) |
| 219 | .await |
| 220 | .expect("user"); |
| 221 | let repo = state |
| 222 | .store |
| 223 | .create_repo(&owner.id, "proj", "", Visibility::Private, "main") |
| 224 | .await |
| 225 | .expect("repo"); |
| 226 | let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path"); |
| 227 | init_bare(&state.git, &bare, "main") |
| 228 | .await |
| 229 | .expect("init bare"); |
| 230 | let work = tmp.path().join("work"); |
| 231 | git(tmp.path(), &["init", "-b", "main", "work"]); |
| 232 | let sealed = forge_core::secret::seal(&KEY, b"s").expect("seal"); |
| 233 | let hook = state |
| 234 | .store |
| 235 | .create_webhook( |
| 236 | &repo.id, |
| 237 | &owner.id, |
| 238 | "https://hooks.example/h", |
| 239 | sealed.as_bytes(), |
| 240 | &[ |
| 241 | WebhookEvent::Push, |
| 242 | WebhookEvent::Create, |
| 243 | WebhookEvent::Delete, |
| 244 | ], |
| 245 | true, |
| 246 | ) |
| 247 | .await |
| 248 | .expect("hook"); |
| 249 | Fixture { |
| 250 | _tmp: tmp, |
| 251 | state, |
| 252 | repo, |
| 253 | owner_id: owner.id, |
| 254 | hook_id: hook.id, |
| 255 | bare, |
| 256 | work, |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | impl Fixture { |
| 261 | |
| 262 | |
| 263 | async fn update_ref(&self, refname: &str, old: &str, new: &str) { |
| 264 | let bare = self.bare.to_str().expect("utf8"); |
| 265 | if new == ZERO { |
| 266 | git(&self.work, &["push", bare, &format!(":{refname}")]); |
| 267 | } else { |
| 268 | git(&self.work, &["push", bare, &format!("{new}:{refname}")]); |
| 269 | } |
| 270 | after_ref_update(&self.state, &self.repo, &self.owner_id, refname, old, new) |
| 271 | .await |
| 272 | .expect("post-update"); |
| 273 | } |
| 274 | |
| 275 | |
| 276 | async fn deliveries(&self, event: WebhookEvent) -> Vec<(Delivery, Value)> { |
| 277 | let mut rows = self |
| 278 | .state |
| 279 | .store |
| 280 | .list_deliveries(&self.hook_id, 50) |
| 281 | .await |
| 282 | .expect("list"); |
| 283 | rows.reverse(); |
| 284 | rows.into_iter() |
| 285 | .filter(|d| d.event == event) |
| 286 | .map(|d| { |
| 287 | let body = serde_json::from_str(&d.request_body).expect("json body"); |
| 288 | (d, body) |
| 289 | }) |
| 290 | .collect() |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | #[tokio::test] |
| 295 | async fn push_through_post_update_enqueues_before_and_after() { |
| 296 | let fx = fixture().await; |
| 297 | let c1 = commit(&fx.work, "a.txt", "one"); |
| 298 | fx.update_ref("refs/heads/main", ZERO, &c1).await; |
| 299 | |
| 300 | let pushes = fx.deliveries(WebhookEvent::Push).await; |
| 301 | assert_eq!(pushes.len(), 1); |
| 302 | let (_, body) = &pushes[0]; |
| 303 | assert_eq!(body["ref"], "refs/heads/main"); |
| 304 | assert_eq!(body["before"], ZERO); |
| 305 | assert_eq!(body["after"], c1.as_str()); |
| 306 | assert_eq!(body["total_commits"], 1); |
| 307 | assert_eq!(body["commits"][0]["id"], c1.as_str()); |
| 308 | assert_eq!(body["commits"][0]["message"], "one"); |
| 309 | |
| 310 | |
| 311 | let creates = fx.deliveries(WebhookEvent::Create).await; |
| 312 | assert_eq!(creates.len(), 1); |
| 313 | assert_eq!(creates[0].1["ref"], "main"); |
| 314 | assert_eq!(creates[0].1["ref_type"], "branch"); |
| 315 | |
| 316 | let c2 = commit(&fx.work, "b.txt", "two"); |
| 317 | fx.update_ref("refs/heads/main", &c1, &c2).await; |
| 318 | let pushes = fx.deliveries(WebhookEvent::Push).await; |
| 319 | assert_eq!(pushes.len(), 2); |
| 320 | let (_, body) = &pushes[1]; |
| 321 | assert_eq!(body["before"], c1.as_str()); |
| 322 | assert_eq!(body["after"], c2.as_str()); |
| 323 | assert_eq!(body["total_commits"], 1); |
| 324 | assert_eq!(body["commits"][0]["id"], c2.as_str()); |
| 325 | |
| 326 | |
| 327 | |
| 328 | fx.update_ref("refs/heads/topic", ZERO, &c2).await; |
| 329 | fx.update_ref("refs/heads/topic", &c2, ZERO).await; |
| 330 | let deletes = fx.deliveries(WebhookEvent::Delete).await; |
| 331 | assert_eq!(deletes.len(), 1); |
| 332 | assert_eq!(deletes[0].1["ref"], "topic"); |
| 333 | assert_eq!(deletes[0].1["ref_type"], "branch"); |
| 334 | let pushes = fx.deliveries(WebhookEvent::Push).await; |
| 335 | assert_eq!(pushes.len(), 4); |
| 336 | assert_eq!(pushes[3].1["ref"], "refs/heads/topic"); |
| 337 | assert_eq!(pushes[3].1["after"], ZERO); |
| 338 | assert_eq!(pushes[3].1["total_commits"], 0); |
| 339 | } |
| 340 | |
| 341 | #[tokio::test] |
| 342 | async fn refs_outside_heads_and_tags_produce_no_events() { |
| 343 | let fx = fixture().await; |
| 344 | let c1 = commit(&fx.work, "a.txt", "one"); |
| 345 | let bare = fx.bare.to_str().expect("utf8"); |
| 346 | git(&fx.work, &["push", bare, &format!("{c1}:refs/notes/x")]); |
| 347 | after_ref_update(&fx.state, &fx.repo, &fx.owner_id, "refs/notes/x", ZERO, &c1) |
| 348 | .await |
| 349 | .expect("post-update"); |
| 350 | assert!(fx.deliveries(WebhookEvent::Push).await.is_empty()); |
| 351 | assert!(fx.deliveries(WebhookEvent::Create).await.is_empty()); |
| 352 | } |
| 353 | } |