Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

505 linesCodeBlameRaw
1//! Auto-merge for one pull request, as a person asked (`set_auto_merge`):
2//! it merges by itself, as that person, once everything it needs is met.
3//!
4//! The same events that move g1t's own lifecycle along ask again
5//! (`Work::advance`): checks finishing, reviews, being marked ready,
6//! pushes, and its base moving. When it can merge, it merges as whoever
7//! turned auto-merge on, with the method and message they chose, if they
8//! may still merge it. When it cannot merge as things are (it conflicts,
9//! it failed in the merge queue, the method is no longer allowed, they
10//! can no longer merge), auto-merge turns off with a note saying why.
11//!
12//! It turns off too when the pull request merges or closes, when anyone
13//! but whoever turned it on pushes to it, and when the repository stops
14//! allowing auto-merge.
15
16use g1t_contracts::access::{self, Capability};
17use g1t_contracts::events::PullEvent;
18use g1t_contracts::repos::{GetByIdArgs, Repo, RepoPath};
19use g1t_contracts::rules::MergeMethod;
20use g1t_contracts::time::rfc3339;
21use g1t_contracts::work::*;
22use g1t_contracts::{FailureCode, Outcome, User};
23use g1t_kit::now_ms;
24use g1t_rules::merge::method_label;
25use serde::{Deserialize, Serialize};
26use worker::Result;
27
28use crate::lifecycle::{POLICY_ACTOR_ID, POLICY_ACTOR_NAME};
29use crate::merging;
30use crate::retired::writable;
31use crate::rows::ValueRow;
32use crate::{Work, allowed, no_pull};
33
34/// How long a merge auto-merge started is waited for before another may
35/// start.
36const MERGE_MINUTES: u64 = 2;
37
38/// Unwraps an `Outcome`, returning its failure from the enclosing method.
39macro_rules! check {
40 ($outcome:expr) => {
41 match $outcome {
42 Outcome::Ok(value) => value,
43 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
44 }
45 };
46}
47
48/// Auto-merge as stored (`pulls.auto_merge`): what the contract shows, and
49/// the account to merge as. `Pull::auto_merge` reads the same JSON and
50/// leaves the account out.
51#[derive(Clone, Debug, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase")]
53pub(crate) struct StoredAutoMerge {
54 #[serde(flatten)]
55 pub(crate) merge: AutoMerge,
56 pub(crate) actor: User,
57}
58
59/// What to do about a pull request waiting to merge on its own.
60#[derive(Debug, PartialEq, Eq)]
61pub(crate) enum Step {
62 /// Not yet: something it needs is still missing, which may come.
63 Wait,
64 /// It cannot merge as things are: turn auto-merge off, saying why.
65 Disable(String),
66 /// Everything is met: merge it.
67 Merge,
68}
69
70/// Everything the next step depends on.
71#[derive(Debug, Default)]
72pub(crate) struct Facts {
73 pub(crate) status: Option<PullStatus>,
74 /// The repository still allows auto-merge.
75 pub(crate) allowed_here: bool,
76 /// Whoever turned it on may still merge it.
77 pub(crate) can_merge: bool,
78 /// Why its method can no longer be used, if it cannot.
79 pub(crate) method_refusal: Option<String>,
80 /// The files it conflicts in, when known to conflict.
81 pub(crate) conflicting: Option<Vec<String>>,
82 pub(crate) behind: bool,
83 pub(crate) require_up_to_date: bool,
84 /// What the rules for its base still want, if anything.
85 pub(crate) gate_refusal: Option<String>,
86}
87
88/// The next step for a pull request waiting to merge on its own. `who` is
89/// whoever turned it on; `base` the branch it merges into.
90pub(crate) fn step(facts: &Facts, who: &str, base: &str) -> Step {
91 match facts.status {
92 Some(PullStatus::Open) => {}
93 // A draft may wait: it merges once it is marked ready.
94 Some(PullStatus::Draft) => return Step::Wait,
95 _ => return Step::Disable("It is no longer open.".to_owned()),
96 }
97 if !facts.allowed_here {
98 return Step::Disable("This repository no longer allows auto-merge.".to_owned());
99 }
100 if !facts.can_merge {
101 return Step::Disable(format!("{who} can no longer merge it."));
102 }
103 if let Some(refusal) = &facts.method_refusal {
104 return Step::Disable(refusal.clone());
105 }
106 if let Some(files) = &facts.conflicting {
107 let named = if files.is_empty() { String::new() } else { format!(" in {}", files.join(", ")) };
108 return Step::Disable(format!("It conflicts with {base}{named}. Resolve the conflicts and turn auto-merge on again."));
109 }
110 if facts.behind && facts.require_up_to_date {
111 return Step::Wait;
112 }
113 if facts.gate_refusal.is_some() {
114 return Step::Wait;
115 }
116 Step::Merge
117}
118
119/// Whether a push by `pusher` (a user id) turns auto-merge off: anyone's
120/// but whoever turned it on. A push g1t made itself, with no one behind
121/// it, does not.
122pub(crate) fn cleared_by_push(stored: &StoredAutoMerge, pusher: Option<&str>) -> bool {
123 pusher.is_some_and(|pusher| pusher != stored.actor.id)
124}
125
126impl Work {
127 /// Auto-merge as stored for a pull request, with the account to merge as.
128 async fn stored_auto_merge(&self, pull_id: &str) -> Result<Option<StoredAutoMerge>> {
129 let row = self
130 .db
131 .prepare("SELECT auto_merge AS value FROM pulls WHERE id = ? AND auto_merge IS NOT NULL")
132 .bind(&[pull_id.into()])?
133 .first::<ValueRow>(None)
134 .await?;
135 Ok(row.and_then(|row| serde_json::from_str(&row.value).ok()))
136 }
137
138 /// Turns a pull request's auto-merge off, if it is on, with a note
139 /// saying why: `by` turned it off, or g1t did (`by` is none) because of
140 /// `reason`. Publishes `pull.auto_merge_disabled`.
141 pub(crate) async fn disable_auto_merge(&self, pull: &Pull, by: Option<&User>, reason: Option<&str>) -> Result<bool> {
142 let cleared = self
143 .db
144 .prepare("UPDATE pulls SET auto_merge = NULL WHERE id = ? AND auto_merge IS NOT NULL RETURNING id AS value")
145 .bind(&[pull.id.as_str().into()])?
146 .first::<ValueRow>(None)
147 .await?;
148 if cleared.is_none() {
149 return Ok(false);
150 }
151 let text = match reason {
152 Some(reason) => format!("turned off auto-merge: {reason}"),
153 None => "turned off auto-merge".to_owned(),
154 };
155 let who = by.map_or((POLICY_ACTOR_ID, POLICY_ACTOR_NAME), |user| (user.id.as_str(), user.username.as_str()));
156 self.note(&pull.repo_id, pull.number, who, &text).await?;
157 let event = PullEvent {
158 detail: reason.map(str::to_owned),
159 auto_merge_by: pull.auto_merge.as_ref().map(|auto| auto.enabled_by.clone()),
160 ..Self::pull_event(pull)
161 };
162 match by {
163 Some(user) => self.publish("pull.auto_merge_disabled", &pull.repo_id, user, event).await?,
164 None => self.publish_as("pull.auto_merge_disabled", &pull.repo_id, None, event).await?,
165 }
166 Ok(true)
167 }
168
169 /// `set_auto_merge`: turns a pull request's auto-merge on or off.
170 pub(crate) async fn set_auto_merge(&self, a: SetAutoMergeArgs) -> Result<Outcome<Pull>> {
171 if !a.actor.verified {
172 return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED));
173 }
174 let (repo, mut pull) = check!(self.pull_at(&a.repo, a.number, &Some(a.actor.clone())).await?);
175 check!(writable(&repo));
176 // It merges as whoever turns it on, so only someone who may merge
177 // can; its author may also turn it off.
178 if a.enabled || !pull.is_owned_by(&a.actor.id) {
179 check!(allowed(Some(&a.actor), &repo, Capability::Merge));
180 }
181 if !pull.status.is_active() {
182 return Ok(Outcome::fail(FailureCode::Conflict, format!("This pull request is already {}.", pull.status.as_str())));
183 }
184 if !a.enabled {
185 self.disable_auto_merge(&pull, Some(&a.actor), None).await?;
186 return Ok(match self.pull(&repo.id, pull.number).await? {
187 Some(pull) => Outcome::Ok(pull),
188 None => no_pull(),
189 });
190 }
191 crate::fill_base(&mut pull, &repo);
192 let base = pull.base_branch(&repo.default_branch).to_owned();
193 let (settings, requirements) = self.settings_and_requirements(&repo, &pull).await?;
194 if !settings.allow_auto_merge {
195 return Ok(Outcome::fail(
196 FailureCode::Forbidden,
197 format!("Auto-merge is turned off for {}/{}. Its admins can turn it on in Settings.", repo.namespace, repo.name),
198 ));
199 }
200 let method = match merging::choose_method(a.merge_method, &settings, &requirements, &base) {
201 Ok(method) => method,
202 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
203 };
204 // What they wrote is checked now, not when it merges.
205 if let Err(message) = self
206 .merge_message_for(&repo, &pull, &settings, method, a.commit_title.as_deref(), a.commit_message.as_deref())
207 .await?
208 {
209 return Ok(Outcome::fail(FailureCode::Invalid, message));
210 }
211 if let Some(files) = self.conflicting_files(&pull).await? {
212 let named = if files.is_empty() { String::new() } else { format!(" in {}", files.join(", ")) };
213 return Ok(Outcome::fail(
214 FailureCode::Conflict,
215 format!("This branch has conflicts with {base}{named} that must be resolved first. Merge {base} into it, fix them and push."),
216 ));
217 }
218 // One that may merge already merges now.
219 if pull.status == PullStatus::Open {
220 let facts = self.auto_merge_facts(&repo, &pull, &a.actor, method).await?;
221 if step(&facts, &a.actor.username, &base) == Step::Merge {
222 return self.merge_pull(merge_args(&a.actor, &repo, pull.number, method, a.commit_title, a.commit_message)).await;
223 }
224 }
225 let stored = StoredAutoMerge {
226 merge: AutoMerge {
227 method,
228 commit_title: a.commit_title.map(|title| title.trim().to_owned()),
229 commit_message: a.commit_message,
230 enabled_by: a.actor.username.clone(),
231 enabled_at: rfc3339(now_ms()),
232 },
233 actor: a.actor.clone(),
234 };
235 let set = self
236 .db
237 .prepare("UPDATE pulls SET auto_merge = ? WHERE id = ? AND status IN ('draft', 'open') RETURNING id AS value")
238 .bind(&[serde_json::to_string(&stored)?.into(), pull.id.as_str().into()])?
239 .first::<ValueRow>(None)
240 .await?;
241 if set.is_none() {
242 return Ok(Outcome::fail(FailureCode::Conflict, "This pull request is no longer open."));
243 }
244 let changed = pull.auto_merge.as_ref().is_none_or(|before| before.method != method);
245 if changed {
246 self.note(
247 &pull.repo_id,
248 pull.number,
249 (&a.actor.id, &a.actor.username),
250 &format!("turned on auto-merge ({})", method_label(method)),
251 )
252 .await?;
253 self.publish(
254 "pull.auto_merge_enabled",
255 &pull.repo_id,
256 &a.actor,
257 PullEvent { detail: Some(method.as_str().to_owned()), ..Self::pull_event(&pull) },
258 )
259 .await?;
260 }
261 Ok(match self.pull(&repo.id, pull.number).await? {
262 Some(pull) => Outcome::Ok(pull),
263 None => no_pull(),
264 })
265 }
266
267 /// Where a pull request stands for merging on its own by `method`, as
268 /// `actor`.
269 async fn auto_merge_facts(&self, repo: &Repo, pull: &Pull, actor: &User, method: MergeMethod) -> Result<Facts> {
270 let base = pull.base_branch(&repo.default_branch).to_owned();
271 let (settings, requirements) = self.settings_and_requirements(repo, pull).await?;
272 let method_refusal = merging::choose_method(Some(method), &settings, &requirements, &base).err();
273 let gate = self.merge_gate_by(repo, pull, Some(actor), false, false, method).await?;
274 let (behind, conflicting) = futures_util::future::try_join(self.is_behind(pull), self.conflicting_files(pull)).await?;
275 Ok(Facts {
276 status: Some(pull.status),
277 allowed_here: settings.allow_auto_merge,
278 can_merge: actor.verified && access::can(Some(actor), repo, Capability::Merge),
279 method_refusal,
280 conflicting,
281 behind,
282 require_up_to_date: settings.require_up_to_date,
283 gate_refusal: gate.refusal(),
284 })
285 }
286
287 /// Merges a pull request waiting to merge on its own, if everything it
288 /// needs is met; turns auto-merge off, saying why, if it cannot merge
289 /// as things are.
290 pub(crate) async fn try_auto_merge(&self, pull: &Pull) -> Result<()> {
291 let Some(stored) = self.stored_auto_merge(&pull.id).await? else {
292 return Ok(());
293 };
294 let who = stored.actor.username.clone();
295 // As whoever turned it on: a repository they can no longer read is
296 // one they can no longer merge into.
297 let repo: Outcome<Repo> = g1t_kit::call(
298 &self.repos,
299 "get_by_id",
300 &GetByIdArgs { id: pull.repo_id.clone(), viewer: Some(stored.actor.clone()) },
301 )
302 .await?;
303 let repo = match crate::retired::unless_archived(repo) {
304 Outcome::Ok(repo) => repo,
305 Outcome::Fail(failure) if failure.code == FailureCode::NotFound => {
306 self.disable_auto_merge(pull, None, Some(&format!("{who} can no longer merge it."))).await?;
307 return Ok(());
308 }
309 // Archived: nothing merges, and nothing changes.
310 Outcome::Fail(_) => return Ok(()),
311 };
312 // Held to what they may do now, as well as to what they could when
313 // they asked (a token's narrower scopes): neither may have changed
314 // for the worse since.
315 let now: g1t_contracts::Viewer =
316 g1t_kit::call(&self.identity, "user_by_id", &g1t_contracts::identity::UserIdArgs { id: stored.actor.id.clone() }).await?;
317 if !now.as_ref().is_some_and(|now| now.verified && access::can(Some(now), &repo, Capability::Merge)) {
318 self.disable_auto_merge(pull, None, Some(&format!("{who} can no longer merge it."))).await?;
319 return Ok(());
320 }
321 let mut pull = pull.clone();
322 crate::fill_base(&mut pull, &repo);
323 let base = pull.base_branch(&repo.default_branch).to_owned();
324 let facts = self.auto_merge_facts(&repo, &pull, &stored.actor, stored.merge.method).await?;
325 match step(&facts, &who, &base) {
326 Step::Wait => return Ok(()),
327 Step::Disable(reason) => {
328 self.disable_auto_merge(&pull, None, Some(&reason)).await?;
329 return Ok(());
330 }
331 Step::Merge => {}
332 }
333 // One merge at a time, however many events ask.
334 if !self.claim(&pull.id, "merge", MERGE_MINUTES).await? {
335 return Ok(());
336 }
337 let merged = self
338 .merge_pull(merge_args(
339 &stored.actor,
340 &repo,
341 pull.number,
342 stored.merge.method,
343 stored.merge.commit_title.clone(),
344 stored.merge.commit_message.clone(),
345 ))
346 .await?;
347 self.db
348 .prepare("UPDATE pulls SET working_on = NULL, working_until = NULL WHERE id = ? AND working_on = 'merge'")
349 .bind(&[pull.id.as_str().into()])?
350 .run()
351 .await?;
352 match merged {
353 // Merged, queued, or waiting for the push that catches it up to
354 // fast-forward (lifecycle.rs `land_if_requested`).
355 Outcome::Ok(merged) => {
356 if merged.status == PullStatus::Open && self.queued_entry(&pull.id).await?.is_some() {
357 // In the merge queue it lands as the queue decides.
358 self.disable_auto_merge(&pull, None, Some("It joined the merge queue.")).await?;
359 }
360 }
361 Outcome::Fail(failure) => {
362 self.disable_auto_merge(&pull, None, Some(&format!("It could not be merged: {}", failure.message))).await?;
363 }
364 }
365 Ok(())
366 }
367
368 /// A push moved a pull request: auto-merge another person turned on
369 /// turns off (`cleared_by_push`).
370 pub(crate) async fn auto_merge_after_push(&self, pull: &Pull, pusher: Option<&str>) -> Result<()> {
371 if pull.auto_merge.is_none() {
372 return Ok(());
373 }
374 let Some(stored) = self.stored_auto_merge(&pull.id).await? else {
375 return Ok(());
376 };
377 if cleared_by_push(&stored, pusher) {
378 let reason = format!("someone other than {} pushed to it.", stored.actor.username);
379 self.disable_auto_merge(pull, None, Some(&reason)).await?;
380 }
381 Ok(())
382 }
383
384 /// The repository stopped allowing auto-merge: every pull request
385 /// waiting to merge on its own stops waiting.
386 pub(crate) async fn auto_merge_turned_off(&self, repo: &Repo, actor: &User) -> Result<()> {
387 #[derive(Deserialize)]
388 struct Waiting {
389 id: String,
390 }
391 let waiting = self
392 .db
393 .prepare("SELECT id FROM pulls WHERE repo_id = ? AND auto_merge IS NOT NULL AND status IN ('draft', 'open')")
394 .bind(&[repo.id.as_str().into()])?
395 .all()
396 .await?
397 .results::<Waiting>()?;
398 for row in waiting {
399 if let Some(pull) = self.pull_by_id(&row.id).await? {
400 self.disable_auto_merge(&pull, Some(actor), Some("this repository no longer allows auto-merge.")).await?;
401 }
402 }
403 Ok(())
404 }
405}
406
407/// What merging as auto-merge asks: `actor`'s merge by `method`.
408fn merge_args(
409 actor: &User,
410 repo: &Repo,
411 number: u32,
412 method: MergeMethod,
413 commit_title: Option<String>,
414 commit_message: Option<String>,
415) -> PullActionArgs {
416 PullActionArgs {
417 actor: actor.clone(),
418 repo: RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() },
419 number,
420 summary: String::new(),
421 keep_issue_open: false,
422 ignore_checks: false,
423 bypass_rules: false,
424 merge_method: Some(method),
425 commit_title,
426 commit_message,
427 expected_head: None,
428 delete_branch: None,
429 update_method: None,
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 fn ready() -> Facts {
438 Facts { status: Some(PullStatus::Open), allowed_here: true, can_merge: true, ..Facts::default() }
439 }
440
441 #[test]
442 fn it_merges_once_everything_is_met() {
443 assert_eq!(step(&ready(), "ana", "main"), Step::Merge);
444 }
445
446 #[test]
447 fn what_may_still_come_is_waited_for() {
448 assert_eq!(step(&Facts { status: Some(PullStatus::Draft), ..ready() }, "ana", "main"), Step::Wait);
449 assert_eq!(step(&Facts { gate_refusal: Some("The required check CI has not finished.".into()), ..ready() }, "ana", "main"), Step::Wait);
450 assert_eq!(step(&Facts { behind: true, require_up_to_date: true, ..ready() }, "ana", "main"), Step::Wait);
451 // Behind is no reason to wait where it need not be up to date.
452 assert_eq!(step(&Facts { behind: true, ..ready() }, "ana", "main"), Step::Merge);
453 }
454
455 #[test]
456 fn what_cannot_change_by_itself_turns_it_off() {
457 let off = |facts: Facts| match step(&facts, "ana", "main") {
458 Step::Disable(reason) => reason,
459 other => panic!("{other:?}"),
460 };
461 assert_eq!(off(Facts { status: Some(PullStatus::Closed), ..ready() }), "It is no longer open.");
462 assert_eq!(off(Facts { allowed_here: false, ..ready() }), "This repository no longer allows auto-merge.");
463 assert_eq!(off(Facts { can_merge: false, ..ready() }), "ana can no longer merge it.");
464 assert_eq!(off(Facts { method_refusal: Some("main requires linear history".into()), ..ready() }), "main requires linear history");
465 assert_eq!(
466 off(Facts { conflicting: Some(vec!["a.rs".into()]), ..ready() }),
467 "It conflicts with main in a.rs. Resolve the conflicts and turn auto-merge on again."
468 );
469 // A conflict outweighs waiting on checks.
470 assert!(matches!(
471 step(&Facts { conflicting: Some(Vec::new()), gate_refusal: Some("x".into()), ..ready() }, "ana", "main"),
472 Step::Disable(_)
473 ));
474 }
475
476 fn stored(actor_id: &str) -> StoredAutoMerge {
477 StoredAutoMerge {
478 merge: AutoMerge {
479 method: MergeMethod::Squash,
480 commit_title: None,
481 commit_message: None,
482 enabled_by: "ana".into(),
483 enabled_at: String::new(),
484 },
485 actor: User { id: actor_id.into(), username: "ana".into(), ..User::default() },
486 }
487 }
488
489 #[test]
490 fn a_push_by_anyone_else_turns_it_off() {
491 let on = stored("usr_ana");
492 assert!(!cleared_by_push(&on, Some("usr_ana")));
493 assert!(cleared_by_push(&on, Some("usr_bo")));
494 assert!(!cleared_by_push(&on, None), "g1t's own bookkeeping");
495 }
496
497 #[test]
498 fn what_is_stored_reads_back_as_the_contract_shows_it() {
499 let json = serde_json::to_string(&stored("usr_ana")).unwrap();
500 let shown: AutoMerge = serde_json::from_str(&json).unwrap();
501 assert_eq!((shown.method, shown.enabled_by.as_str()), (MergeMethod::Squash, "ana"));
502 let back: StoredAutoMerge = serde_json::from_str(&json).unwrap();
503 assert_eq!(back.actor.id, "usr_ana");
504 }
505}