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.

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