forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
13.0 KiB
1//! Side effects of one updated ref, shared by the post-receive hook and the web merge route.
2//!
3//! [`after_ref_update`] runs, in order:
4//! 0. Webhook events for the ref: push, and create or delete (see `webhooks::emit`).
5//! 1. Closing keywords and references from the commits a default-branch update added. These run
6//! in a spawned task and are not coalesced: every range must be processed.
7//! 2. Pull request sync for `refs/heads/<b>` (see `pulls_sync`), inline.
8//!
9//! Maintenance is not part of this: it is coalesced per repo and runs once per push through
10//! `internal::trigger_maintenance`. Callers that update a ref outside receive-pack (the merge
11//! route) call it themselves.
12
13use std::path::PathBuf;
14
15use forge_core::issue_refs::{find_closing_refs, find_issue_refs};
16use forge_core::{IssueState, Repo, StateReason};
17use forge_git::history;
18use serde_json::json;
19
20use crate::internal::is_zero_oid;
21use crate::notify::{self, Activity};
22use crate::pulls_sync;
23use crate::state::AppState;
24use crate::webhooks::emit::{Sender, emit_item_action, emit_ref_update, lookup_sender};
25
26/// Commits read for a push that updates an existing branch.
27const MAX_PUSH_COMMITS: usize = 500;
28/// Commits read for a push that creates the branch.
29const MAX_NEW_BRANCH_COMMITS: usize = 50;
30
31/// Runs the per-ref post-update work for `refname`, moving `old` to `new`. `actor_id` is the user
32/// who made the change (empty for system updates). Closing-keyword work is spawned; pull request
33/// sync is awaited, so its errors are returned, while per-pull failures are logged and skipped.
34pub 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/// Processes the commits added to the default branch by one ref update, oldest first.
76async 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/// Closes open issues named by closing keywords in `text`, and records a `referenced` event on
108/// every other issue it mentions, once per (issue, commit).
109async 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)]
162mod 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 /// A repo with a bare copy on disk, a hook subscribed to push and create, and a work tree.
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 /// Pushes `new` to `refname` on the bare repo (`ZERO` deletes it) and runs the post-update
262 /// work the hook would run.
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 /// The hook's deliveries of `event`, oldest first, with their bodies parsed.
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 // Creating the branch also sends a create event for it.
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 // A topic branch: create, then delete. Git will not delete the bare repo's
327 // current branch, so the delete uses a branch that is not HEAD.
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}