Skip to content
855 linesCodeBlameRaw
1//! Where a pull request stands, and merging it on the repository's say.
2//!
3//! A pull request g1t's agent once made (`made_by_g1t`, `managed = 1`)
4//! still shows where it stands: its checks, the approvals the branch
5//! wants, the merge queue, and whether a person has to step in. Where the
6//! repository's settings say ready pull requests land by themselves
7//! (`auto_merge`), such a pull request is merged here once everything its
8//! branch's rules ask for is met, attributed to `g1t` (`POLICY_ACTOR_ID`).
9//!
10//! A merge asked for while a pull request is behind is recorded here
11//! (`request_landing`) and lands once the push that brings it up to date
12//! arrives (`land_if_requested`).
13
14use g1t_contracts::repos::{GetByIdArgs, Repo, RepoPath};
15use g1t_contracts::time::rfc3339;
16use g1t_contracts::work::*;
17use g1t_contracts::{FailureCode, Membership, Outcome, User, Viewer};
18use g1t_kit::now_ms;
19use std::collections::HashMap;
20
21use serde::Deserialize;
22use worker::Result;
23use worker::wasm_bindgen::JsValue;
24
25use crate::Work;
26use crate::prefetch::Slot;
27use crate::reviews::{AGENT_ID, AGENT_NAME};
28use crate::statuses::{self, WorkflowFacts};
29use crate::rows::ValueRow;
30
31/// How long a claimed step is waited for before it may be taken again.
32const CATCH_UP_MINUTES: u64 = 30;
33const MERGE_MINUTES: u64 = 2;
34/// Who a merge made by a repository's settings is attributed to. Not an
35/// account: `g1t` cannot be registered.
36pub(crate) const POLICY_ACTOR_ID: &str = "g1t_policy";
37pub(crate) const POLICY_ACTOR_NAME: &str = "g1t";
38const MANAGED_PAGE: u32 = 200;
39
40/// The part of a pull request's row that tracks its lifecycle.
41#[derive(Deserialize)]
42struct Progress {
43 managed: u8,
44 revisions: u32,
45 revised_at: Option<String>,
46 working_on: Option<String>,
47 working_until: Option<String>,
48 stalled: Option<String>,
49}
50
51/// A merge asked for while the pull request was behind, as stored.
52#[derive(serde::Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54struct LandRequest {
55 actor: User,
56 keep_issue_open: bool,
57}
58
59#[derive(Deserialize)]
60struct LandRow {
61 land_requested: Option<String>,
62 land_requested_at: Option<String>,
63 stalled: Option<String>,
64}
65
66/// What should happen next, if it is g1t's turn.
67pub(crate) enum Next {
68 /// A step is under way, or it is a person's turn.
69 Wait,
70 /// Land it, because the repository says ready pull requests land.
71 Merge,
72}
73
74/// Whether g1t's agent made this pull request, and so g1t shows where it
75/// stands. Only pull requests from before g1t stopped hosting agents.
76pub(crate) fn made_by_g1t(pull: &Pull) -> bool {
77 pull.runtime == Runtime::Hosted && pull.agent == AGENT_NAME && pull.fork.is_some()
78}
79
80/// Whether a verdict by `reviewer` is a person's other than the pull
81/// request's owner (whoever asked g1t for it, or its author): neither
82/// theirs nor g1t's agent's.
83pub(crate) fn from_someone_else(pull: &Pull, reviewer: &str) -> bool {
84 !pull.is_owned_by(reviewer) && reviewer != AGENT_ID
85}
86
87fn at(stage: Stage, detail: impl Into<String>, revisions: u32) -> Lifecycle {
88 Lifecycle {
89 stage,
90 detail: detail.into(),
91 revisions,
92 }
93}
94
95/// Everything the next step depends on.
96struct Facts {
97 /// Still being made: not yet marked ready for review.
98 draft: bool,
99 /// Why g1t stopped, if it has.
100 stalled: Option<String>,
101 /// The step under way, if one was claimed and is still being waited for.
102 working_on: Option<String>,
103 /// `failed` when the merge queue took it out.
104 check_status: Option<CheckStatus>,
105 revisions: u32,
106 /// Whether the branch it would land on has moved without it.
107 behind: bool,
108 /// Whether it is known to conflict with the branch it would land on.
109 conflicting: bool,
110 /// Whether the repository lands a ready pull request by itself.
111 auto_merge: bool,
112 /// Whether the repository refuses to merge one that is behind.
113 require_up_to_date: bool,
114 /// What the repository's approval rule still wants, if anything.
115 approvals_missing: Option<String>,
116 /// The person who asked for changes since the last revision, if one did.
117 person_request: Option<String>,
118 /// Its place in the merge queue, and what is ahead of it there.
119 queued: Option<(QueueState, Vec<u32>)>,
120 /// What the checks on its head say, against the required ones.
121 workflows: WorkflowFacts,
122}
123
124/// Where a pull request stands, and whether to merge it.
125///
126/// The order is: nothing while a step is under way; a person asking for
127/// changes holds it; its checks must finish, and the required ones pass;
128/// it must not conflict, and where the repository insists, it must be up
129/// to date; then the approvals the branch wants. Once everything is met,
130/// it merges where the repository says so.
131fn decide(facts: Facts) -> (Lifecycle, Next) {
132 let revisions = facts.revisions;
133 let wait = |stage, detail: &str| (at(stage, detail, revisions), Next::Wait);
134
135 if facts.draft {
136 return wait(Stage::Working, "It is still a draft.");
137 }
138 if let Some(reason) = &facts.stalled {
139 return wait(Stage::NeedsYou, reason);
140 }
141 if let Some((state, ahead)) = &facts.queued {
142 let named = ahead.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", ");
143 let detail = match (state, ahead.is_empty()) {
144 (QueueState::Testing, true) => "In the merge queue: being tested on the default branch as it is.".to_owned(),
145 (QueueState::Testing, false) => {
146 format!("In the merge queue: being tested together with {named}, ahead of it.")
147 }
148 (QueueState::Passed, true) => "Passed in the merge queue. Landing.".to_owned(),
149 (QueueState::Passed, false) => {
150 format!("Passed in the merge queue together with {named}. It lands once they have.")
151 }
152 _ => "In the merge queue, waiting for its turn to be tested.".to_owned(),
153 };
154 return wait(Stage::Queued, &detail);
155 }
156 match facts.working_on.as_deref() {
157 Some("catch_up") => {
158 return wait(Stage::CatchingUp, "Bringing it up to date with the branch it will land on.");
159 }
160 Some("merge") => return wait(Stage::Ready, "Merging."),
161 _ => {}
162 }
163 if let Some(name) = &facts.person_request {
164 return wait(Stage::NeedsYou, &format!("{name} asked for changes."));
165 }
166 match facts.check_status {
167 Some(CheckStatus::Failed) => return wait(Stage::NeedsYou, "It failed in the merge queue."),
168 Some(CheckStatus::Errored) => return wait(Stage::NeedsYou, "Its checks could not be run."),
169 _ => {}
170 }
171 // Only the checks the branch requires hold it.
172 let required = facts.workflows.required_failed();
173 if !required.is_empty() {
174 return wait(
175 Stage::NeedsYou,
176 &format!(
177 "The required {} {} {}.",
178 if required.len() == 1 { "check" } else { "checks" },
179 statuses::list(&required),
180 if required.len() == 1 { "fails" } else { "fail" },
181 ),
182 );
183 }
184 if !facts.workflows.pending.is_empty() {
185 return wait(
186 Stage::Checking,
187 &format!("Waiting for {} to finish.", statuses::list(&facts.workflows.pending)),
188 );
189 }
190 let expected = facts.workflows.expected();
191 if !expected.is_empty() {
192 return wait(
193 Stage::Checking,
194 &format!(
195 "Waiting for the required {} {} to report on its latest commit.",
196 if expected.len() == 1 { "check" } else { "checks" },
197 statuses::list(&expected)
198 ),
199 );
200 }
201 if facts.conflicting {
202 return wait(
203 Stage::NeedsYou,
204 "It conflicts with the branch it will land on. Merge that branch in, resolve the conflicts and push.",
205 );
206 }
207 if facts.behind && facts.require_up_to_date {
208 return wait(
209 Stage::NeedsYou,
210 "The branch it will land on has moved, and this repository requires pull requests to be up to date. Catch up with it first.",
211 );
212 }
213 if let Some(missing) = &facts.approvals_missing {
214 return wait(Stage::NeedsYou, missing);
215 }
216 if facts.auto_merge {
217 return (
218 at(
219 Stage::Ready,
220 "Everything this repository asks for is met. Merging, as its settings say.",
221 revisions,
222 ),
223 Next::Merge,
224 );
225 }
226 wait(
227 Stage::Ready,
228 if facts.behind {
229 "Ready to merge. Merging brings it up to date with the default branch first."
230 } else {
231 "Everything this repository asks for is met. Ready to merge."
232 },
233 )
234}
235
236impl Work {
237 /// Where a pull request stands and whether g1t merges it, remembered
238 /// so lists can show it without working it out again. `None` for one
239 /// g1t is not seeing through.
240 pub(crate) async fn assess(
241 &self,
242 pull: &Pull,
243 _issue: &Option<Issue>,
244 behind: bool,
245 ) -> Result<Option<(Lifecycle, Next)>> {
246 let assessed = self.assess_now(pull, behind).await?;
247 if let Some((lifecycle, _)) = &assessed
248 && !self.remembered_as(&pull.id, lifecycle)?
249 {
250 self.remember(&pull.id, lifecycle).await?;
251 }
252 Ok(assessed)
253 }
254
255 /// Whether the row read for this request already says `lifecycle`, so
256 /// that showing a pull request does not write it again unchanged.
257 fn remembered_as(&self, pull_id: &str, lifecycle: &Lifecycle) -> Result<bool> {
258 let Some(found) = self.prefetched_pull(pull_id) else {
259 return Ok(false);
260 };
261 Ok(found
262 .first::<crate::rows::Snapshot>(Slot::Pull)?
263 .is_some_and(|stored| {
264 stored.stage == Some(lifecycle.stage) && stored.stage_detail.as_deref() == Some(lifecycle.detail.as_str())
265 }))
266 }
267
268 /// Saves where a pull request stands.
269 pub(crate) async fn remember(&self, pull_id: &str, lifecycle: &Lifecycle) -> Result<()> {
270 let stage = serde_json::to_value(lifecycle.stage)?;
271 self.db
272 .prepare("UPDATE pulls SET stage = ?, stage_detail = ? WHERE id = ?")
273 .bind(&[
274 stage.as_str().unwrap_or_default().into(),
275 lifecycle.detail.as_str().into(),
276 pull_id.into(),
277 ])?
278 .run()
279 .await?;
280 Ok(())
281 }
282
283 async fn assess_now(&self, pull: &Pull, behind: bool) -> Result<Option<(Lifecycle, Next)>> {
284 if !pull.status.is_active() {
285 return Ok(None);
286 }
287 // As read for this request (prefetch.rs), or now.
288 let prefetched = self.prefetched_pull(&pull.id);
289 let progress = match &prefetched {
290 Some(found) => found.first::<Progress>(Slot::Pull)?,
291 None => {
292 self.db
293 .prepare(
294 "SELECT managed, revisions, revised_at, working_on, working_until, stalled
295 FROM pulls WHERE id = ?",
296 )
297 .bind(&[pull.id.as_str().into()])?
298 .first::<Progress>(None)
299 .await?
300 }
301 };
302 let Some(progress) = progress.filter(|progress| progress.managed != 0) else {
303 return Ok(None);
304 };
305 let now = rfc3339(now_ms());
306 let working_on = progress
307 .working_until
308 .as_deref()
309 .is_some_and(|until| until > now.as_str())
310 .then(|| progress.working_on.clone().unwrap_or_default());
311 let settings = self.settings_for(pull).await?;
312 let statuses = self.statuses(&pull.repo_id, pull.head_commit.as_deref()).await?;
313 let workflows = self.workflow_facts(&pull.repo_id, pull.head_commit.as_deref(), &statuses, &settings.required_checks).await?;
314 // The rules of the branch it merges into, as g1t sees them when it
315 // merges by itself: what people must still do, and whether an
316 // agent's change may land here unattended.
317 let gate = match self.repo_for(pull).await? {
318 Some(repo) => Some(self.merge_gate(&repo, pull, Some(&User::system(&repo.namespace)), false, true).await?),
319 None => None,
320 };
321 let rules_allow_auto_merge = gate
322 .as_ref()
323 .is_none_or(|gate| g1t_rules::merge::auto_merge_refusal(&gate.requirements).is_none());
324 let (lifecycle, next) = decide(Facts {
325 draft: pull.status == PullStatus::Draft,
326 stalled: progress.stalled,
327 working_on,
328 check_status: pull.check_status,
329 revisions: progress.revisions,
330 behind,
331 conflicting: self.conflicting_files(pull).await?.is_some(),
332 auto_merge: settings.auto_merge && rules_allow_auto_merge,
333 require_up_to_date: settings.require_up_to_date,
334 approvals_missing: gate.as_ref().and_then(|gate| gate.people_gap()),
335 person_request: self.person_request(pull, progress.revised_at.as_deref()).await?,
336 queued: self.queued_entry(&pull.id).await?,
337 workflows,
338 });
339 Ok(Some((lifecycle, next)))
340 }
341
342 /// Who last asked for changes, if it was a person other than its owner
343 /// (whoever asked g1t for it, or its author), that person's latest
344 /// verdict, and after the last revision.
345 async fn person_request(&self, pull: &Pull, revised_at: Option<&str>) -> Result<Option<String>> {
346 #[derive(Deserialize)]
347 struct Verdicts {
348 author_id: String,
349 author_name: String,
350 verdict: Verdict,
351 created_at: String,
352 }
353 let rows = match self.prefetched_pull(&pull.id) {
354 Some(found) => found
355 .rows::<Verdicts>(Slot::Verdicts)?
356 .into_iter()
357 .filter(|row| from_someone_else(pull, &row.author_id))
358 .collect(),
359 None => self
360 .db
361 .prepare(
362 "SELECT author_id, author_name, verdict, created_at FROM comments
363 WHERE repo_id = ? AND number = ? AND verdict IS NOT NULL
364 AND author_id != ? AND author_id != ?
365 ORDER BY id",
366 )
367 .bind(&[
368 pull.repo_id.as_str().into(),
369 pull.number.into(),
370 pull.owner().id.as_str().into(),
371 AGENT_ID.into(),
372 ])?
373 .all()
374 .await?
375 .results::<Verdicts>()?,
376 };
377 // Each person's latest verdict is the one that stands.
378 let mut latest: HashMap<String, Verdicts> = HashMap::new();
379 for row in rows {
380 latest.insert(row.author_id.clone(), row);
381 }
382 Ok(latest
383 .into_values()
384 .filter(|row| row.verdict == Verdict::RequestChanges)
385 .filter(|row| revised_at.is_none_or(|revised| row.created_at.as_str() > revised))
386 .max_by(|a, b| a.created_at.cmp(&b.created_at))
387 .map(|row| row.author_name))
388 }
389
390 /// Takes a step for a pull request, if nobody else has. One statement,
391 /// so that two callers cannot both take it.
392 pub(crate) async fn claim(&self, pull_id: &str, step: &str, minutes: u64) -> Result<bool> {
393 let now = now_ms();
394 Ok(self
395 .db
396 .prepare(
397 "UPDATE pulls SET working_on = ?2, working_until = ?3
398 WHERE id = ?4 AND status = 'open' AND stalled IS NULL
399 AND (working_until IS NULL OR working_until < ?1)
400 RETURNING id AS value",
401 )
402 .bind(&[
403 rfc3339(now).into(),
404 step.into(),
405 rfc3339(now + minutes * 60 * 1000).into(),
406 pull_id.into(),
407 ])?
408 .first::<ValueRow>(None)
409 .await?
410 .is_some())
411 }
412
413 /// Merges a pull request g1t is seeing through once it is ready, where
414 /// the repository's settings say ready pull requests land by
415 /// themselves. Returns what the runner is to do, which is now always
416 /// nothing: no step is carried out in a sandbox any more.
417 pub(crate) async fn advance(&self, a: AdvanceArgs) -> Result<Advance> {
418 let Some(pull) = self.pull_by_id(&a.pull_id).await? else {
419 return Ok(Advance::None);
420 };
421 if pull.status != PullStatus::Open {
422 return Ok(Advance::None);
423 }
424 // As a member: a private repository would look missing otherwise,
425 // and the pull request would never move.
426 let viewer: Viewer = self.owner_viewer(&pull).await?;
427 let repo: Outcome<Repo> = g1t_kit::call(
428 &self.repos,
429 "get_by_id",
430 &GetByIdArgs {
431 id: pull.repo_id.clone(),
432 viewer,
433 },
434 )
435 .await?;
436 let Outcome::Ok(repo) = crate::retired::unless_archived(repo) else {
437 return Ok(Advance::None);
438 };
439 let behind = self.is_behind(&repo.id, &pull).await?;
440 if let Some((_, Next::Merge)) = self.assess(&pull, &None, behind).await? {
441 self.merge_by_policy(&repo, &pull).await?;
442 }
443 Ok(Advance::None)
444 }
445
446 /// Lands a pull request that is ready, on the authority of the
447 /// repository's settings instead of a person's click.
448 async fn merge_by_policy(&self, repo: &Repo, pull: &Pull) -> Result<()> {
449 if !self.claim(&pull.id, "merge", MERGE_MINUTES).await? {
450 return Ok(());
451 }
452 // g1t acts for the workspace whose members turned this on, with the
453 // default base permission (Write), which merging needs.
454 let actor = User {
455 id: POLICY_ACTOR_ID.to_owned(),
456 username: POLICY_ACTOR_NAME.to_owned(),
457 verified: true,
458 workspaces: vec![Membership::member(repo.namespace.to_lowercase())],
459 ..User::default()
460 };
461 let merged = self
462 .merge_pull(PullActionArgs {
463 actor,
464 repo: RepoPath {
465 namespace: repo.namespace.clone(),
466 name: repo.name.clone(),
467 },
468 number: pull.number,
469 summary: String::new(),
470 keep_issue_open: false,
471 ignore_checks: false,
472 bypass_rules: false,
473 })
474 .await?;
475 match merged {
476 Outcome::Ok(_) => Ok(()),
477 // Most likely the branch moved in the moment between: let go, and
478 // the next look at it tries again.
479 Outcome::Fail(failure) if failure.code == FailureCode::Conflict => {
480 self.db
481 .prepare(
482 "UPDATE pulls SET working_on = NULL, working_until = NULL
483 WHERE id = ? AND working_on = 'merge'",
484 )
485 .bind(&[pull.id.as_str().into()])?
486 .run()
487 .await?;
488 Ok(())
489 }
490 Outcome::Fail(failure) => {
491 self.stall(StallArgs {
492 pull_id: pull.id.clone(),
493 reason: format!("g1t could not merge this: {}", failure.message),
494 by: None,
495 })
496 .await?;
497 Ok(())
498 }
499 }
500 }
501
502 /// Records that a merge was asked for while the pull request was
503 /// behind, so that it lands once the push that brings it up to date
504 /// arrives, and announces it.
505 pub(crate) async fn request_landing(
506 &self,
507 pull: &Pull,
508 actor: &User,
509 keep_issue_open: bool,
510 ) -> Result<()> {
511 let now = now_ms();
512 let request = serde_json::to_string(&LandRequest {
513 actor: actor.clone(),
514 keep_issue_open,
515 })?;
516 self.db
517 .prepare(
518 "UPDATE pulls
519 SET land_requested = ?, land_requested_at = ?, stalled = NULL,
520 working_on = 'catch_up', working_until = ?
521 WHERE id = ?",
522 )
523 .bind(&[
524 request.into(),
525 rfc3339(now).into(),
526 rfc3339(now + CATCH_UP_MINUTES * 60 * 1000).into(),
527 pull.id.as_str().into(),
528 ])?
529 .run()
530 .await?;
531 self.publish(
532 "pull.merge_requested",
533 &pull.repo_id,
534 actor,
535 Self::pull_event(pull),
536 )
537 .await
538 }
539
540 /// The merge waiting on a pull request, if one was asked for recently
541 /// enough to still stand.
542 async fn land_request(&self, pull_id: &str) -> Result<Option<LandRequest>> {
543 let row = self.land_row(pull_id).await?;
544 let oldest = rfc3339(now_ms().saturating_sub(CATCH_UP_MINUTES * 60 * 1000));
545 Ok(row
546 .filter(|row| {
547 row.land_requested_at
548 .as_deref()
549 .is_some_and(|at| at >= oldest.as_str())
550 })
551 .and_then(|row| row.land_requested)
552 .and_then(|request| serde_json::from_str(&request).ok()))
553 }
554
555 /// Whether a merge is waiting on a pull request, and why g1t stopped
556 /// working on it if it did.
557 pub(crate) async fn landing_state(&self, pull_id: &str) -> Result<(bool, Option<String>)> {
558 let stalled = self.land_row(pull_id).await?.and_then(|row| row.stalled);
559 Ok((self.land_request(pull_id).await?.is_some(), stalled))
560 }
561
562 /// The merge waiting on a pull request and why g1t stopped, as read
563 /// for this request (prefetch.rs) or now.
564 async fn land_row(&self, pull_id: &str) -> Result<Option<LandRow>> {
565 if let Some(found) = self.prefetched_pull(pull_id) {
566 return found.first::<LandRow>(Slot::Pull);
567 }
568 self.db
569 .prepare("SELECT land_requested, land_requested_at, stalled FROM pulls WHERE id = ?")
570 .bind(&[pull_id.into()])?
571 .first::<LandRow>(None)
572 .await
573 }
574
575 /// Forgets a merge that was waiting on a pull request, and the step
576 /// that was bringing it up to date.
577 pub(crate) async fn forget_landing(&self, pull_id: &str) -> Result<()> {
578 self.db
579 .prepare(
580 "UPDATE pulls SET land_requested = NULL, land_requested_at = NULL,
581 working_on = CASE WHEN working_on = 'catch_up' THEN NULL ELSE working_on END,
582 working_until = CASE WHEN working_on = 'catch_up' THEN NULL ELSE working_until END
583 WHERE id = ?",
584 )
585 .bind(&[pull_id.into()])?
586 .run()
587 .await?;
588 Ok(())
589 }
590
591 /// Lands a pull request whose head has just moved, if a merge of it was
592 /// waiting for exactly that. What it was caught up to was already
593 /// checked and reviewed apart from the merge, so the checks are not
594 /// waited for again; a repository that wants them rerun turns on
595 /// "require up to date", and then nothing is landed this way.
596 pub(crate) async fn land_if_requested(&self, pull_id: &str) -> Result<()> {
597 let Some(request) = self.land_request(pull_id).await? else {
598 return Ok(());
599 };
600 self.forget_landing(pull_id).await?;
601 let Some(pull) = self.pull_by_id(pull_id).await? else {
602 return Ok(());
603 };
604 let repo: Outcome<Repo> = g1t_kit::call(
605 &self.repos,
606 "get_by_id",
607 &GetByIdArgs {
608 id: pull.repo_id.clone(),
609 viewer: Some(request.actor.clone()),
610 },
611 )
612 .await?;
613 let Outcome::Ok(repo) = crate::retired::unless_archived(repo) else {
614 return Ok(());
615 };
616 let merged = self
617 .merge_pull(PullActionArgs {
618 actor: request.actor,
619 repo: RepoPath {
620 namespace: repo.namespace,
621 name: repo.name,
622 },
623 number: pull.number,
624 summary: String::new(),
625 keep_issue_open: request.keep_issue_open,
626 ignore_checks: true,
627 bypass_rules: false,
628 })
629 .await?;
630 if let Outcome::Fail(failure) = merged {
631 self.stall(StallArgs {
632 pull_id: pull.id,
633 reason: format!(
634 "It was brought up to date but could not be merged: {}",
635 failure.message
636 ),
637 by: None,
638 })
639 .await?;
640 }
641 Ok(())
642 }
643
644 /// Stops seeing a pull request through until a person steps in. The
645 /// first stop is published (`pull.stalled`), which tells its people
646 /// that it needs them; a stop on one already stopped only says why.
647 pub(crate) async fn stall(&self, a: StallArgs) -> Result<bool> {
648 let before = self.pull_by_id(&a.pull_id).await?;
649 let was_stalled = self.is_stalled(&a.pull_id).await?;
650 self.db
651 .prepare(
652 "UPDATE pulls
653 SET stalled = ?, working_on = NULL, working_until = NULL,
654 land_requested = NULL, land_requested_at = NULL
655 WHERE id = ? AND status = 'open'",
656 )
657 .bind(&[a.reason.trim().into(), a.pull_id.as_str().into()])?
658 .run()
659 .await?;
660 self.db
661 .prepare("UPDATE pulls SET stage = 'needs_you', stage_detail = ? WHERE id = ?")
662 .bind(&[a.reason.trim().into(), a.pull_id.as_str().into()])?
663 .run()
664 .await?;
665 if let Some(pull) = before.filter(|pull| !was_stalled && pull.status == PullStatus::Open) {
666 self.publish_as(
667 "pull.stalled",
668 &pull.repo_id,
669 a.by.clone(),
670 g1t_contracts::events::PullEvent {
671 detail: Some(a.reason.trim().to_owned()),
672 ..Self::pull_event(&pull)
673 },
674 )
675 .await?;
676 }
677 Ok(true)
678 }
679
680 /// Whether g1t has stopped seeing the pull request through.
681 pub(crate) async fn is_stalled(&self, pull_id: &str) -> Result<bool> {
682 Ok(self
683 .db
684 .prepare("SELECT 1 AS value FROM pulls WHERE id = ? AND stalled IS NOT NULL")
685 .bind(&[pull_id.into()])?
686 .first::<u32>(Some("value"))
687 .await?
688 .is_some())
689 }
690
691 /// Says that a pull request g1t had stopped on is going again, which
692 /// closes what it was waiting on a person for.
693 pub(crate) async fn announce_resumed(&self, pull_id: &str, actor: Option<String>) -> Result<()> {
694 if let Some(pull) = self.pull_by_id(pull_id).await? {
695 self.publish_as("pull.resumed", &pull.repo_id, actor, Self::pull_event(&pull)).await?;
696 }
697 Ok(())
698 }
699
700 pub(crate) async fn managed_pulls(&self, a: ManagedPullsArgs) -> Result<Vec<String>> {
701 let rows = self
702 .db
703 .prepare(
704 "SELECT id AS value FROM pulls
705 WHERE status = 'open' AND managed = 1 AND stalled IS NULL
706 AND (?1 IS NULL OR repo_id = ?1)
707 ORDER BY updated_at DESC LIMIT ?2",
708 )
709 .bind(&[
710 a.repo_id.map_or(JsValue::NULL, JsValue::from),
711 MANAGED_PAGE.into(),
712 ])?
713 .all()
714 .await?
715 .results::<ValueRow>()?;
716 Ok(rows.into_iter().map(|row| row.value).collect())
717 }
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723
724 #[test]
725 fn a_verdict_from_whoever_asked_for_g1t_s_change_is_not_someone_else_s() {
726 use crate::rows::stored::{ASKER, G1T, pull};
727 let made = pull(G1T, Some(ASKER));
728 assert!(made_by_g1t(&made));
729 assert!(!from_someone_else(&made, ASKER.0));
730 assert!(!from_someone_else(&made, AGENT_ID));
731 assert!(from_someone_else(&made, "usr_reviewer"));
732 let own = pull(ASKER, None);
733 assert!(!from_someone_else(&own, ASKER.0));
734 assert!(from_someone_else(&own, "usr_reviewer"));
735 }
736
737 /// Checks on a head commit: `(context, state)` statuses, against the
738 /// required check names.
739 fn checks(statuses: &[(&str, &str)], required: &[&str]) -> WorkflowFacts {
740 let statuses: Vec<CommitStatus> = statuses
741 .iter()
742 .map(|(context, state)| CommitStatus {
743 context: (*context).to_owned(),
744 state: (*state).to_owned(),
745 description: None,
746 target_url: None,
747 updated_at: String::new(),
748 source: None,
749 check_run_id: None,
750 })
751 .collect();
752 let required: Vec<String> = required.iter().map(|name| (*name).to_owned()).collect();
753 WorkflowFacts::of(&statuses, &required)
754 }
755
756 /// A pull request that is ready for review, whose required check
757 /// passed, and nothing else yet.
758 fn facts() -> Facts {
759 Facts {
760 draft: false,
761 stalled: None,
762 working_on: None,
763 check_status: None,
764 revisions: 0,
765 behind: false,
766 conflicting: false,
767 auto_merge: false,
768 require_up_to_date: false,
769 approvals_missing: None,
770 person_request: None,
771 queued: None,
772 workflows: checks(&[("CI / pull_request", "success")], &["CI"]),
773 }
774 }
775
776 /// The stage, and a word for the step to take.
777 fn outcome(facts: Facts) -> (Stage, &'static str) {
778 let (lifecycle, next) = decide(facts);
779 let step = match next {
780 Next::Wait => "wait",
781 Next::Merge => "merge",
782 };
783 (lifecycle.stage, step)
784 }
785
786 #[test]
787 fn a_ready_change_lands_by_itself_only_where_the_repository_says_so() {
788 assert_eq!(outcome(facts()), (Stage::Ready, "wait"));
789 assert_eq!(outcome(Facts { auto_merge: true, ..facts() }), (Stage::Ready, "merge"));
790 // One that is behind is merged too: merging brings it up to date.
791 assert_eq!(outcome(Facts { auto_merge: true, behind: true, ..facts() }), (Stage::Ready, "merge"));
792 // Unless the repository wants it caught up first.
793 let strict = Facts { auto_merge: true, behind: true, require_up_to_date: true, ..facts() };
794 assert_eq!(outcome(strict), (Stage::NeedsYou, "wait"));
795 }
796
797 #[test]
798 fn approvals_the_repository_wants_are_waited_for() {
799 let short = Facts {
800 approvals_missing: Some("This repository requires 1 approving review.".to_owned()),
801 auto_merge: true,
802 ..facts()
803 };
804 let (lifecycle, next) = decide(short);
805 assert_eq!(lifecycle.stage, Stage::NeedsYou);
806 assert_eq!(lifecycle.detail, "This repository requires 1 approving review.");
807 assert!(matches!(next, Next::Wait));
808 }
809
810 #[test]
811 fn checks_are_waited_for_and_a_failing_required_one_needs_a_person() {
812 let running = Facts { workflows: checks(&[("CI / pull_request", "pending")], &["CI"]), auto_merge: true, ..facts() };
813 let (lifecycle, next) = decide(running);
814 assert_eq!(lifecycle.stage, Stage::Checking);
815 assert!(lifecycle.detail.contains("Waiting for CI / pull_request"));
816 assert!(matches!(next, Next::Wait));
817 let failed = Facts { workflows: checks(&[("CI / pull_request", "failure")], &["CI"]), ..facts() };
818 let (lifecycle, _) = decide(failed);
819 assert_eq!(lifecycle.stage, Stage::NeedsYou);
820 assert_eq!(lifecycle.detail, "The required check CI fails.");
821 // A check the branch does not require does not hold it.
822 let lint = checks(&[("CI / pull_request", "success"), ("Lint / pull_request", "failure")], &["CI"]);
823 assert_eq!(outcome(Facts { workflows: lint, ..facts() }), (Stage::Ready, "wait"));
824 let missing = Facts { workflows: checks(&[("CI / pull_request", "success")], &["CI", "Deploy"]), auto_merge: true, ..facts() };
825 let (lifecycle, next) = decide(missing);
826 assert_eq!(lifecycle.detail, "Waiting for the required check Deploy to report on its latest commit.");
827 assert!(matches!(next, Next::Wait), "auto-merge must not land it");
828 }
829
830 #[test]
831 fn the_merge_queue_conflicts_and_people_hold_it() {
832 let queued = Facts { queued: Some((QueueState::Testing, vec![12, 14])), auto_merge: true, ..facts() };
833 let (lifecycle, _) = decide(queued);
834 assert_eq!(lifecycle.stage, Stage::Queued);
835 assert!(lifecycle.detail.contains("#12, #14"));
836 assert_eq!(outcome(Facts { check_status: Some(CheckStatus::Failed), auto_merge: true, ..facts() }), (Stage::NeedsYou, "wait"));
837 assert_eq!(outcome(Facts { check_status: Some(CheckStatus::Errored), ..facts() }), (Stage::NeedsYou, "wait"));
838 assert_eq!(outcome(Facts { conflicting: true, auto_merge: true, ..facts() }), (Stage::NeedsYou, "wait"));
839 let asked = Facts { person_request: Some("ana".to_owned()), auto_merge: true, ..facts() };
840 let (lifecycle, next) = decide(asked);
841 assert_eq!(lifecycle.detail, "ana asked for changes.");
842 assert!(matches!(next, Next::Wait));
843 }
844
845 #[test]
846 fn drafts_steps_under_way_and_stops_wait() {
847 assert_eq!(outcome(Facts { draft: true, ..facts() }), (Stage::Working, "wait"));
848 assert_eq!(outcome(Facts { working_on: Some("catch_up".into()), auto_merge: true, ..facts() }), (Stage::CatchingUp, "wait"));
849 assert_eq!(outcome(Facts { working_on: Some("merge".into()), auto_merge: true, ..facts() }), (Stage::Ready, "wait"));
850 let (lifecycle, next) = decide(Facts { stalled: Some("It could not be merged.".to_owned()), auto_merge: true, ..facts() });
851 assert_eq!(lifecycle.stage, Stage::NeedsYou);
852 assert_eq!(lifecycle.detail, "It could not be merged.");
853 assert!(matches!(next, Next::Wait));
854 }
855}