flagon-io/g1t

public

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/services/work/src/lifecycle.rs

1,857 lines71,443 bytesCodeBlame
1//! Seeing a pull request through. Once a g1t agent has made a change, g1t
2//! takes each remaining step itself: waiting for the checks the
3//! repository's workflows report on it, a review by another agent, sending
4//! the author back to address a failed check (with what its jobs printed)
5//! or the review, and catching up when the branch it would land on has
6//! moved. It stops when the pull request meets everything the default
7//! branch's protection requires, or when it has tried and a person has to
8//! decide.
9//!
10//! This service decides what the next step is and claims it. The runner
11//! service asks, on every event that could change the answer, and carries
12//! the step out in a sandbox.
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 REVIEW_MINUTES: u64 = 20;
33const REVISION_MINUTES: u64 = 60;
34const CATCH_UP_MINUTES: u64 = 30;
35const MERGE_MINUTES: u64 = 2;
36/// Who a merge made by a repository's settings is attributed to. Not an
37/// account: `g1t` cannot be registered.
38pub(crate) const POLICY_ACTOR_ID: &str = "g1t_policy";
39pub(crate) const POLICY_ACTOR_NAME: &str = "g1t";
40/// How much of a failed check's output the author is shown.
41const MAX_CHECK_OUTPUT_CHARS: usize = 4_000;
42/// How many failed jobs' logs the author is shown, and how much of each.
43const MAX_FAILED_JOBS: usize = 3;
44const MAX_JOB_LOG_CHARS: usize = 3_000;
45/// The most pages of a job's log read to find its end.
46const MAX_LOG_PAGES: usize = 6;
47const MANAGED_PAGE: u32 = 200;
48
49/// The part of a pull request's row that tracks its lifecycle.
50#[derive(Deserialize)]
51struct Progress {
52 managed: u8,
53 revisions: u32,
54 revised_at: Option<String>,
55 working_on: Option<String>,
56 working_until: Option<String>,
57 stalled: Option<String>,
58}
59
60/// A merge asked for while the pull request was behind, as stored.
61#[derive(serde::Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63struct LandRequest {
64 actor: User,
65 keep_issue_open: bool,
66}
67
68#[derive(Deserialize)]
69struct LandRow {
70 land_requested: Option<String>,
71 land_requested_at: Option<String>,
72 stalled: Option<String>,
73}
74
75#[derive(Deserialize)]
76struct FinishedReview {
77 finished_at: String,
78 verdict: Option<Verdict>,
79}
80
81#[derive(Deserialize)]
82struct ReviewNote {
83 body: String,
84 path: Option<String>,
85 line: Option<u32>,
86}
87
88/// What the author is being sent back to address.
89pub(crate) enum Feedback {
90 /// The merge queue took it out: its combined state failed.
91 FailedChecks,
92 /// Checks that failed on its head.
93 FailedWorkflows,
94 /// The review that finished at this time.
95 Review(String),
96 /// What a person who asked for changes wrote since the last revision.
97 Person(PersonRequest),
98}
99
100/// A person's request for changes that still stands: their latest verdict,
101/// made after the agent last revised.
102#[derive(Clone, Debug, Deserialize)]
103pub(crate) struct PersonRequest {
104 author_id: String,
105 author_name: String,
106 created_at: String,
107}
108
109/// What should happen next, if it is g1t's turn.
110pub(crate) enum Next {
111 /// A step is under way, or it is a person's turn.
112 Wait,
113 Review,
114 Revise(Feedback),
115 CatchUp,
116 /// Land it, because the repository says ready pull requests land.
117 Merge,
118}
119
120/// Whether g1t made this pull request, and so sees it through.
121pub(crate) fn made_by_g1t(pull: &Pull) -> bool {
122 pull.runtime == Runtime::Hosted && pull.agent == AGENT_NAME && pull.fork.is_some()
123}
124
125fn at(stage: Stage, detail: impl Into<String>, revisions: u32) -> Lifecycle {
126 Lifecycle {
127 stage,
128 detail: detail.into(),
129 revisions,
130 }
131}
132
133fn times(count: u32) -> String {
134 match count {
135 1 => "once".to_owned(),
136 2 => "twice".to_owned(),
137 count => format!("{count} times"),
138 }
139}
140
141/// Everything the next step depends on.
142struct Facts {
143 /// Still being made: not yet marked ready for review.
144 draft: bool,
145 /// Why g1t stopped, if it has.
146 stalled: Option<String>,
147 /// The step under way, if one was claimed and is still being waited for.
148 working_on: Option<String>,
149 /// `failed` when the merge queue took it out.
150 check_status: Option<CheckStatus>,
151 /// A review someone asked for is being written.
152 review_pending: bool,
153 revisions: u32,
154 /// The latest finished review of the change as it is now.
155 review: Option<FinishedReview>,
156 /// Whether the branch it would land on has moved without it.
157 behind: bool,
158 /// Whether it is known to conflict with the branch it would land on.
159 conflicting: bool,
160 /// Whether the repository lands a ready pull request by itself.
161 auto_merge: bool,
162 /// Whether the repository refuses to merge one that is behind.
163 require_up_to_date: bool,
164 /// Whether a second agent reviews it without being asked.
165 agent_review: bool,
166 /// How many times the author may be sent back.
167 max_revisions: u32,
168 /// What the repository's approval rule still wants, if anything.
169 approvals_missing: Option<String>,
170 /// A person asked for changes since the agent last revised.
171 person_request: Option<PersonRequest>,
172 /// Its place in the merge queue, and what is ahead of it there.
173 queued: Option<(QueueState, Vec<u32>)>,
174 /// What the checks on its head say, against the required ones.
175 workflows: WorkflowFacts,
176 /// Why the agent's change has low confidence, when the repository asks
177 /// a person before merging one and no person has approved it since.
178 low_confidence: Option<String>,
179}
180
181/// Where a pull request stands, and the step to take if it is g1t's turn.
182///
183/// The order is: nothing while a step is under way; a person asking for
184/// changes is answered first; its checks must finish, and the required
185/// ones pass; then a review must approve; then it must be up to date. A
186/// person's or an agent's request for changes, or a failed check, sends the
187/// author back, a limited number of times, after which a person is asked.
188/// A check the branch does not require stops holding it once the
189/// revisions run out.
190fn decide(facts: Facts) -> (Lifecycle, Next) {
191 let revisions = facts.revisions;
192 let wait = |stage, detail: &str| (at(stage, detail, revisions), Next::Wait);
193 let exhausted = revisions >= facts.max_revisions;
194
195 if facts.draft {
196 return wait(Stage::Working, "A g1t agent is making the change.");
197 }
198 if let Some(reason) = &facts.stalled {
199 return wait(Stage::NeedsYou, reason);
200 }
201 if let Some((state, ahead)) = &facts.queued {
202 let named = ahead.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", ");
203 let detail = match (state, ahead.is_empty()) {
204 (QueueState::Testing, true) => "In the merge queue: being tested on the default branch as it is.".to_owned(),
205 (QueueState::Testing, false) => {
206 format!("In the merge queue: being tested together with {named}, ahead of it.")
207 }
208 (QueueState::Passed, true) => "Passed in the merge queue. Landing.".to_owned(),
209 (QueueState::Passed, false) => {
210 format!("Passed in the merge queue together with {named}. It lands once they have.")
211 }
212 _ => "In the merge queue, waiting for its turn to be tested.".to_owned(),
213 };
214 return wait(Stage::Queued, &detail);
215 }
216 match facts.working_on.as_deref() {
217 Some("revision") => {
218 return wait(
219 Stage::Revising,
220 "The agent is addressing what the checks or the review found.",
221 );
222 }
223 Some("catch_up") => {
224 return wait(
225 Stage::CatchingUp,
226 "The agent is merging in the branch this will land on, which has moved.",
227 );
228 }
229 Some("answer") => {
230 return wait(
231 Stage::Answering,
232 "The agent is answering what another agent asked it.",
233 );
234 }
235 Some("merge") => return wait(Stage::Ready, "Merging."),
236 Some(_) => return wait(Stage::Reviewing, "A g1t agent is reviewing the change."),
237 None => {}
238 }
239 if facts.review_pending {
240 return wait(Stage::Reviewing, "A g1t agent is reviewing the change.");
241 }
242 // A person asked for changes: the agent makes them, as it would for a
243 // review it asked for, before anything else.
244 if let Some(request) = &facts.person_request {
245 if exhausted {
246 return wait(
247 Stage::NeedsYou,
248 &format!(
249 "{} asked for changes, and the agent has already revised {}.",
250 request.author_name,
251 times(revisions)
252 ),
253 );
254 }
255 return (
256 at(
257 Stage::Revising,
258 format!(
259 "{} asked for changes. The agent is being sent back to make them.",
260 request.author_name
261 ),
262 revisions,
263 ),
264 Next::Revise(Feedback::Person(request.clone())),
265 );
266 }
267
268 // The merge queue took it out: its change failed together with what
269 // was ahead of it.
270 match facts.check_status {
271 Some(CheckStatus::Failed) if exhausted => {
272 return wait(
273 Stage::NeedsYou,
274 &format!("It failed in the merge queue after the agent revised {}.", times(revisions)),
275 );
276 }
277 Some(CheckStatus::Failed) => {
278 return (
279 at(
280 Stage::Revising,
281 "It failed in the merge queue. The agent is being sent back to fix it.",
282 revisions,
283 ),
284 Next::Revise(Feedback::FailedChecks),
285 );
286 }
287 Some(CheckStatus::Errored) => {
288 return wait(Stage::NeedsYou, "Its checks could not be run.");
289 }
290 _ => {}
291 }
292
293 // Its checks: the agent fixes any that failed. Once it is out of
294 // revisions, only the checks the branch requires still hold it.
295 if !facts.workflows.failed.is_empty() {
296 let failed = statuses::list(&facts.workflows.failed);
297 let required = facts.workflows.required_failed();
298 if !exhausted {
299 return (
300 at(
301 Stage::Revising,
302 format!("{failed} failed. The agent is being sent back to fix it."),
303 revisions,
304 ),
305 Next::Revise(Feedback::FailedWorkflows),
306 );
307 }
308 if !required.is_empty() {
309 return wait(
310 Stage::NeedsYou,
311 &format!(
312 "The required {} {} still {} after the agent revised {}.",
313 if required.len() == 1 { "check" } else { "checks" },
314 statuses::list(&required),
315 if required.len() == 1 { "fails" } else { "fail" },
316 times(revisions)
317 ),
318 );
319 }
320 }
321 if !facts.workflows.pending.is_empty() {
322 return wait(
323 Stage::Checking,
324 &format!("Waiting for {} to finish.", statuses::list(&facts.workflows.pending)),
325 );
326 }
327 let expected = facts.workflows.expected();
328 if !expected.is_empty() {
329 return wait(
330 Stage::Checking,
331 &format!(
332 "Waiting for the required {} {} to report on its latest commit.",
333 if expected.len() == 1 { "check" } else { "checks" },
334 statuses::list(&expected)
335 ),
336 );
337 }
338
339 // A second agent reviews it, unless the repository leaves review to people.
340 if facts.agent_review {
341 match facts.review {
342 None => {
343 return (
344 at(
345 Stage::Reviewing,
346 "A g1t agent is about to review the change.",
347 revisions,
348 ),
349 Next::Review,
350 );
351 }
352 Some(FinishedReview { verdict: None, .. }) => {
353 return wait(Stage::NeedsYou, "The review could not be completed.");
354 }
355 Some(FinishedReview {
356 verdict: Some(Verdict::RequestChanges),
357 ..
358 }) if exhausted => {
359 return wait(
360 Stage::NeedsYou,
361 &format!(
362 "The review still asks for changes after the agent revised {}.",
363 times(revisions)
364 ),
365 );
366 }
367 Some(FinishedReview {
368 verdict: Some(Verdict::RequestChanges),
369 finished_at,
370 }) => {
371 return (
372 at(
373 Stage::Revising,
374 "The review asked for changes. The agent is being sent back to make them.",
375 revisions,
376 ),
377 Next::Revise(Feedback::Review(finished_at)),
378 );
379 }
380 Some(FinishedReview {
381 verdict: Some(Verdict::Approve),
382 ..
383 }) => {}
384 }
385 }
386
387 // A conflict found ahead of time is resolved before anything else that
388 // is left: it could not merge, by a person or by the queue, until then.
389 if facts.conflicting {
390 return (
391 at(
392 Stage::CatchingUp,
393 "It conflicts with the branch it will land on. The agent is merging that branch in and resolving the conflicts.",
394 revisions,
395 ),
396 Next::CatchUp,
397 );
398 }
399 // Only where the repository insists is catching up a step of its own,
400 // followed by its checks again. Elsewhere it happens as part of merging.
401 if facts.behind && facts.require_up_to_date {
402 return (
403 at(
404 Stage::CatchingUp,
405 "The branch it will land on has moved. The agent is catching up.",
406 revisions,
407 ),
408 Next::CatchUp,
409 );
410 }
411 // The repository wants approvals this does not have yet: people's turn,
412 // so it is shown as needing someone, not as g1t still working.
413 if let Some(missing) = &facts.approvals_missing {
414 return wait(Stage::NeedsYou, missing);
415 }
416 // Everything else is met, but g1t is not sure of the change: a person
417 // decides, rather than auto-merge or the queue.
418 if let Some(reasons) = &facts.low_confidence {
419 return wait(
420 Stage::NeedsYou,
421 &format!(
422 "The agent's confidence in this change is low ({reasons}). This repository asks a person before merging it: approve it to let it land, or ask for changes."
423 ),
424 );
425 }
426 if facts.auto_merge {
427 return (
428 at(
429 Stage::Ready,
430 "Everything this repository asks for is met. Merging, as its settings say.",
431 revisions,
432 ),
433 Next::Merge,
434 );
435 }
436 wait(
437 Stage::Ready,
438 if facts.behind {
439 "Ready to merge. Merging brings it up to date with the default branch first."
440 } else {
441 "Everything this repository asks for is met. Ready to merge."
442 },
443 )
444}
445
446impl Work {
447 /// Where a pull request stands and what g1t does next, remembered so
448 /// lists can show it without working it out again. `None` for one g1t
449 /// is not seeing through.
450 pub(crate) async fn assess(
451 &self,
452 pull: &Pull,
453 issue: &Option<Issue>,
454 behind: bool,
455 ) -> Result<Option<(Lifecycle, Next)>> {
456 Ok(self
457 .assess_with_confidence(pull, issue, behind)
458 .await?
459 .map(|(lifecycle, next, _)| (lifecycle, next)))
460 }
461
462 /// [`Self::assess`], with how sure g1t is of the change once the agent
463 /// has finished it.
464 pub(crate) async fn assess_with_confidence(
465 &self,
466 pull: &Pull,
467 issue: &Option<Issue>,
468 behind: bool,
469 ) -> Result<Option<(Lifecycle, Next, Option<Confidence>)>> {
470 let assessed = self.assess_now(pull, issue, behind).await?;
471 if let Some((lifecycle, _, _)) = &assessed
472 && !self.remembered_as(&pull.id, lifecycle)?
473 {
474 self.remember(&pull.id, lifecycle).await?;
475 }
476 Ok(assessed)
477 }
478
479 /// Whether the row read for this request already says `lifecycle`, so
480 /// that showing a pull request does not write it again unchanged.
481 fn remembered_as(&self, pull_id: &str, lifecycle: &Lifecycle) -> Result<bool> {
482 let Some(found) = self.prefetched_pull(pull_id) else {
483 return Ok(false);
484 };
485 Ok(found
486 .first::<crate::rows::Snapshot>(Slot::Pull)?
487 .is_some_and(|stored| {
488 stored.stage == Some(lifecycle.stage) && stored.stage_detail.as_deref() == Some(lifecycle.detail.as_str())
489 }))
490 }
491
492 /// Saves where a pull request stands, for [`Self::remembered`].
493 pub(crate) async fn remember(&self, pull_id: &str, lifecycle: &Lifecycle) -> Result<()> {
494 let stage = serde_json::to_value(lifecycle.stage)?;
495 self.db
496 .prepare("UPDATE pulls SET stage = ?, stage_detail = ? WHERE id = ?")
497 .bind(&[
498 stage.as_str().unwrap_or_default().into(),
499 lifecycle.detail.as_str().into(),
500 pull_id.into(),
501 ])?
502 .run()
503 .await?;
504 Ok(())
505 }
506
507 async fn assess_now(
508 &self,
509 pull: &Pull,
510 // What done means is in its body, for the agent; the merge waits on
511 // the branch's required checks, not on the issue.
512 _issue: &Option<Issue>,
513 behind: bool,
514 ) -> Result<Option<(Lifecycle, Next, Option<Confidence>)>> {
515 if !pull.status.is_active() {
516 return Ok(None);
517 }
518 // As read for this request (prefetch.rs), or now.
519 let prefetched = self.prefetched_pull(&pull.id);
520 let progress = match &prefetched {
521 Some(found) => found.first::<Progress>(Slot::Pull)?,
522 None => {
523 self.db
524 .prepare(
525 "SELECT managed, revisions, revised_at, working_on, working_until, stalled
526 FROM pulls WHERE id = ?",
527 )
528 .bind(&[pull.id.as_str().into()])?
529 .first::<Progress>(None)
530 .await?
531 }
532 };
533 let Some(progress) = progress.filter(|progress| progress.managed != 0) else {
534 return Ok(None);
535 };
536 let now = rfc3339(now_ms());
537 let working_on = progress
538 .working_until
539 .as_deref()
540 .is_some_and(|until| until > now.as_str())
541 .then(|| progress.working_on.clone().unwrap_or_default());
542 let review = match &prefetched {
543 Some(found) => found.first::<FinishedReview>(Slot::Review)?,
544 None => {
545 self.db
546 .prepare(
547 "SELECT finished_at, verdict FROM review_runs
548 WHERE pull_id = ? AND finished_at IS NOT NULL ORDER BY id DESC LIMIT 1",
549 )
550 .bind(&[pull.id.as_str().into()])?
551 .first::<FinishedReview>(None)
552 .await?
553 }
554 }
555 // A review of what the change was before its last revision says
556 // nothing about what it is now.
557 .filter(|review| {
558 progress
559 .revised_at
560 .as_deref()
561 .is_none_or(|revised| review.finished_at.as_str() >= revised)
562 });
563 let settings = self.settings(&pull.repo_id).await?;
564 let workflows = WorkflowFacts::of(
565 &self.statuses(&pull.repo_id, pull.head_commit.as_deref()).await?,
566 &settings.required_checks,
567 );
568 // Once the agent has finished the change: how sure g1t is of it, and
569 // whether that holds it for a person.
570 let (confidence, low_confidence) = if pull.status == PullStatus::Draft {
571 (None, None)
572 } else {
573 let review_comments = match &review {
574 Some(review) => self.review_comments(pull, &review.finished_at).await?,
575 None => 0,
576 };
577 let confidence = self
578 .assess_confidence(
579 pull,
580 crate::confidence::Signals {
581 required: crate::confidence::RequiredSignal::of(&workflows.required),
582 queue_failed: pull.check_status == Some(CheckStatus::Failed),
583 revisions: progress.revisions,
584 agent_review: settings.agent_review,
585 review: review.as_ref().and_then(|review| review.verdict),
586 review_comments,
587 ..Default::default()
588 },
589 )
590 .await?;
591 let held = settings.hold_low_confidence
592 && confidence.level == ConfidenceLevel::Low
593 && !self.person_approved(pull, progress.revised_at.as_deref()).await?;
594 let reasons = held.then(|| confidence.reasons.join(", "));
595 (Some(confidence), reasons)
596 };
597 let (lifecycle, next) = decide(Facts {
598 draft: pull.status == PullStatus::Draft,
599 stalled: progress.stalled,
600 working_on,
601 check_status: pull.check_status,
602 review_pending: self.review_pending(&pull.id).await?,
603 revisions: progress.revisions,
604 review,
605 behind,
606 conflicting: self.conflicting_files(pull).await?.is_some(),
607 auto_merge: settings.auto_merge,
608 require_up_to_date: settings.require_up_to_date,
609 agent_review: settings.agent_review,
610 max_revisions: settings.max_revisions,
611 approvals_missing: self.approvals_gap(&settings, pull).await?,
612 person_request: self
613 .person_request(pull, progress.revised_at.as_deref())
614 .await?,
615 queued: self.queued_entry(&pull.id).await?,
616 workflows,
617 low_confidence,
618 });
619 Ok(Some((lifecycle, next, confidence)))
620 }
621
622 /// The latest request for changes by a person other than the author,
623 /// if it is that person's latest verdict and came after the last
624 /// revision.
625 async fn person_request(
626 &self,
627 pull: &Pull,
628 revised_at: Option<&str>,
629 ) -> Result<Option<PersonRequest>> {
630 #[derive(Deserialize)]
631 struct Verdicts {
632 author_id: String,
633 author_name: String,
634 verdict: Verdict,
635 created_at: String,
636 }
637 let rows = match self.prefetched_pull(&pull.id) {
638 Some(found) => found
639 .rows::<Verdicts>(Slot::Verdicts)?
640 .into_iter()
641 .filter(|row| row.author_id != pull.author.id && row.author_id != AGENT_ID)
642 .collect(),
643 None => self
644 .db
645 .prepare(
646 "SELECT author_id, author_name, verdict, created_at FROM comments
647 WHERE repo_id = ? AND number = ? AND verdict IS NOT NULL
648 AND author_id != ? AND author_id != ?
649 ORDER BY id",
650 )
651 .bind(&[
652 pull.repo_id.as_str().into(),
653 pull.number.into(),
654 pull.author.id.as_str().into(),
655 AGENT_ID.into(),
656 ])?
657 .all()
658 .await?
659 .results::<Verdicts>()?,
660 };
661 // Each person's latest verdict is the one that stands.
662 let mut latest: HashMap<String, Verdicts> = HashMap::new();
663 for row in rows {
664 latest.insert(row.author_id.clone(), row);
665 }
666 Ok(latest
667 .into_values()
668 .filter(|row| row.verdict == Verdict::RequestChanges)
669 .filter(|row| revised_at.is_none_or(|revised| row.created_at.as_str() > revised))
670 .max_by(|a, b| a.created_at.cmp(&b.created_at))
671 .map(|row| PersonRequest {
672 author_id: row.author_id,
673 author_name: row.author_name,
674 created_at: row.created_at,
675 }))
676 }
677
678 /// Marks a pull request a g1t agent has just opened as one g1t sees
679 /// through.
680 pub(crate) async fn manage(&self, pull: &Pull) -> Result<()> {
681 if !made_by_g1t(pull) {
682 return Ok(());
683 }
684 self.db
685 .prepare("UPDATE pulls SET managed = 1 WHERE id = ?")
686 .bind(&[pull.id.as_str().into()])?
687 .run()
688 .await?;
689 Ok(())
690 }
691
692 /// Takes a step for a pull request, if nobody else has. One statement,
693 /// so that two callers cannot both take it.
694 pub(crate) async fn claim(&self, pull_id: &str, step: &str, minutes: u64, revising: bool) -> Result<bool> {
695 let now = now_ms();
696 let revision = if revising {
697 ", revisions = revisions + 1, revised_at = ?1"
698 } else {
699 ""
700 };
701 Ok(self
702 .db
703 .prepare(format!(
704 "UPDATE pulls SET working_on = ?2, working_until = ?3{revision}
705 WHERE id = ?4 AND status = 'open' AND stalled IS NULL
706 AND (working_until IS NULL OR working_until < ?1)
707 RETURNING id AS value"
708 ))
709 .bind(&[
710 rfc3339(now).into(),
711 step.into(),
712 rfc3339(now + minutes * 60 * 1000).into(),
713 pull_id.into(),
714 ])?
715 .first::<ValueRow>(None)
716 .await?
717 .is_some())
718 }
719
720 /// What the author is told when sent back: the checks that failed and
721 /// what their failing jobs printed, why the merge queue took it out, or
722 /// the review and its comments on lines.
723 async fn feedback(&self, pull: &Pull, feedback: &Feedback) -> Result<String> {
724 match feedback {
725 Feedback::FailedChecks => {
726 let run = self.latest_checks(&pull.id).await?;
727 // Why it failed, when that is more than a list of commands:
728 // the merge queue saying what broke in the combined state.
729 let why = run.as_ref().and_then(|run| run.error.clone()).map(|error| format!("{error}\n\n")).unwrap_or_default();
730 let failed: Vec<String> = run
731 .map(|run| run.results)
732 .unwrap_or_default()
733 .into_iter()
734 .filter(|result| !result.passed)
735 .map(|result| {
736 let length = result.output.chars().count();
737 let output: String = result
738 .output
739 .chars()
740 .skip(length.saturating_sub(MAX_CHECK_OUTPUT_CHARS))
741 .collect();
742 let exit = result
743 .exit_code
744 .map_or("it was stopped for taking too long".to_owned(), |code| {
745 format!("exit code {code}")
746 });
747 format!("`{}` failed ({exit}):\n\n{}", result.command, output.trim())
748 })
749 .collect();
750 if failed.is_empty() {
751 return Ok(format!(
752 "{why}Find the cause, fix it in your change, and push. Use get_workflow_run and get_job_logs for any workflow named above."
753 ));
754 }
755 Ok(format!(
756 "{why}These commands failed against your change.\n\n{}",
757 failed.join("\n\n")
758 ))
759 }
760 Feedback::FailedWorkflows => {
761 let (statuses, settings) = futures_util::future::try_join(
762 self.statuses(&pull.repo_id, pull.head_commit.as_deref()),
763 self.settings(&pull.repo_id),
764 )
765 .await?;
766 let required = |context: &str| {
767 let name = g1t_contracts::work::check_name(context).0;
768 settings.required_checks.iter().any(|wanted| wanted.eq_ignore_ascii_case(name))
769 };
770 let failing: Vec<&CommitStatus> =
771 statuses.iter().filter(|s| s.state == "failure" || s.state == "error").collect();
772 let failed: Vec<String> = failing
773 .iter()
774 .map(|s| {
775 let run = s.target_url.as_deref().and_then(|url| url.rsplit('/').next()).unwrap_or_default();
776 format!(
777 "- {}{} ({}): run `{run}`, {}",
778 s.context,
779 if required(&s.context) { ", required to merge" } else { "" },
780 s.description.as_deref().unwrap_or("failed"),
781 s.target_url.as_deref().unwrap_or_default()
782 )
783 })
784 .collect();
785 let logs = self.failing_logs(pull, &failing).await.unwrap_or_default();
786 // Named outright: the agent cannot guess it from its fork.
787 let repo = g1t_kit::call::<_, Option<RepoPath>>(
788 &self.repos,
789 "path_by_id",
790 &g1t_contracts::repos::PathByIdArgs { id: pull.repo_id.clone() },
791 )
792 .await?
793 .map(|path| format!("{}/{}", path.namespace, path.name))
794 .unwrap_or_default();
795 let logs = if logs.is_empty() {
796 String::new()
797 } else {
798 format!("\n\nThe end of what the failing jobs printed:\n\n{}", logs.join("\n\n"))
799 };
800 Ok(format!(
801 "These checks failed on your latest commit to {repo}. They are the repository's workflows, run on your pull request:\n\n{}{logs}\n\n\
802 For more, use the `get_workflow_run` tool (repo `{repo}` and the run's id), \
803 then `get_job_logs` for the job that failed. Fix the cause in the code, not the workflow, \
804 unless the workflow itself is wrong. Push, and the checks run again.",
805 failed.join("\n")
806 ))
807 }
808 Feedback::Review(finished_at) => {
809 // Everything a review says is recorded at the moment it finished.
810 let notes = self
811 .db
812 .prepare(
813 "SELECT body, path, line FROM comments
814 WHERE repo_id = ? AND number = ? AND author_id = ? AND created_at = ?
815 ORDER BY id",
816 )
817 .bind(&[
818 pull.repo_id.as_str().into(),
819 pull.number.into(),
820 AGENT_ID.into(),
821 finished_at.as_str().into(),
822 ])?
823 .all()
824 .await?
825 .results::<ReviewNote>()?;
826 let mut on_lines = Vec::new();
827 let mut summary = String::new();
828 for note in notes {
829 match (note.path, note.line) {
830 (Some(path), Some(line)) => {
831 on_lines.push(format!("- `{path}` line {line}: {}", note.body));
832 }
833 (Some(path), None) => on_lines.push(format!("- `{path}`: {}", note.body)),
834 (None, _) => summary = note.body,
835 }
836 }
837 let mut text = format!(
838 "Another agent reviewed your change and asked for changes.\n\n{summary}"
839 );
840 if !on_lines.is_empty() {
841 text.push_str("\n\nIts comments on lines:\n");
842 text.push_str(&on_lines.join("\n"));
843 }
844 Ok(text)
845 }
846 Feedback::Person(request) => {
847 // What they wrote since the agent last revised, which their
848 // request for changes closes.
849 let revised: Option<String> = self
850 .db
851 .prepare("SELECT revised_at AS value FROM pulls WHERE id = ?")
852 .bind(&[pull.id.as_str().into()])?
853 .first::<Option<String>>(Some("value"))
854 .await?
855 .flatten();
856 let notes = self
857 .db
858 .prepare(
859 "SELECT body, path, line FROM comments
860 WHERE repo_id = ? AND number = ? AND author_id = ?
861 AND created_at > ? AND created_at <= ?
862 ORDER BY id",
863 )
864 .bind(&[
865 pull.repo_id.as_str().into(),
866 pull.number.into(),
867 request.author_id.as_str().into(),
868 revised.unwrap_or_default().into(),
869 request.created_at.as_str().into(),
870 ])?
871 .all()
872 .await?
873 .results::<ReviewNote>()?;
874 let mut on_lines = Vec::new();
875 let mut said = Vec::new();
876 for note in notes {
877 match (note.path, note.line) {
878 (Some(path), Some(line)) => {
879 on_lines.push(format!("- `{path}` line {line}: {}", note.body));
880 }
881 (Some(path), None) => on_lines.push(format!("- `{path}`: {}", note.body)),
882 (None, _) => said.push(note.body),
883 }
884 }
885 let mut text = format!(
886 "{} reviewed your change and asked for changes.\n\n{}",
887 request.author_name,
888 said.join("\n\n")
889 );
890 if !on_lines.is_empty() {
891 text.push_str("\n\nTheir comments on lines:\n");
892 text.push_str(&on_lines.join("\n"));
893 }
894 Ok(text)
895 }
896 }
897 }
898
899 /// The end of what the failed jobs of failing workflow runs printed, a
900 /// few jobs at most, for an agent sent back to fix them. Empty where the
901 /// runs or their logs cannot be read.
902 async fn failing_logs(&self, pull: &Pull, failing: &[&CommitStatus]) -> Result<Vec<String>> {
903 use g1t_contracts::actions::{JobLog, LogsArgs, RunArgs, RunDetail};
904 let Some(repo) = g1t_kit::call::<_, Option<RepoPath>>(
905 &self.repos,
906 "path_by_id",
907 &g1t_contracts::repos::PathByIdArgs { id: pull.repo_id.clone() },
908 )
909 .await?
910 else {
911 return Ok(Vec::new());
912 };
913 let viewer = self.author_viewer(pull).await?;
914 let mut out = Vec::new();
915 for status in failing {
916 let Some(run_id) = status
917 .target_url
918 .as_deref()
919 .and_then(|url| url.split("/actions/runs/").nth(1))
920 .map(|rest| rest.split(['/', '?', '#']).next().unwrap_or_default().to_owned())
921 .filter(|id| !id.is_empty())
922 else {
923 continue;
924 };
925 let detail: Outcome<RunDetail> = g1t_kit::call(
926 &self.actions,
927 "run",
928 &RunArgs { repo: repo.clone(), viewer: viewer.clone(), id: run_id },
929 )
930 .await?;
931 let Outcome::Ok(detail) = detail else { continue };
932 let failed_jobs = detail.jobs.into_iter().filter(|job| {
933 job.status == "completed" && matches!(job.conclusion.as_deref(), Some("failure" | "timed_out"))
934 });
935 for job in failed_jobs {
936 if out.len() >= MAX_FAILED_JOBS {
937 return Ok(out);
938 }
939 let mut text = String::new();
940 let mut after = 0;
941 for _ in 0..MAX_LOG_PAGES {
942 let page: Outcome<JobLog> = g1t_kit::call(
943 &self.actions,
944 "logs",
945 &LogsArgs { repo: repo.clone(), viewer: viewer.clone(), job: job.id.clone(), after },
946 )
947 .await?;
948 let Outcome::Ok(page) = page else { break };
949 let Some(last) = page.chunks.last().map(|chunk| chunk.seq) else { break };
950 for chunk in &page.chunks {
951 text.push_str(&chunk.text);
952 if !chunk.text.ends_with('\n') {
953 text.push('\n');
954 }
955 }
956 // Only the end is kept, so the start can go as it is read.
957 let length = text.chars().count();
958 if length > MAX_JOB_LOG_CHARS * 2 {
959 text = text.chars().skip(length - MAX_JOB_LOG_CHARS).collect();
960 }
961 after = last;
962 if page.chunks.len() < 500 {
963 break;
964 }
965 }
966 let length = text.chars().count();
967 let tail: String = text.chars().skip(length.saturating_sub(MAX_JOB_LOG_CHARS)).collect();
968 if !tail.trim().is_empty() {
969 out.push(format!("{} / {}:\n```\n{}\n```", status.context, job.name, tail.trim_end()));
970 }
971 }
972 }
973 Ok(out)
974 }
975
976 pub(crate) async fn advance(&self, a: AdvanceArgs) -> Result<Advance> {
977 let Some(pull) = self.pull_by_id(&a.pull_id).await? else {
978 return Ok(Advance::None);
979 };
980 if pull.status != PullStatus::Open {
981 return Ok(Advance::None);
982 }
983 // As a member: a private repository would look missing otherwise,
984 // and the pull request would never move.
985 let viewer: Viewer = self.author_viewer(&pull).await?;
986 let repo: Outcome<Repo> = g1t_kit::call(
987 &self.repos,
988 "get_by_id",
989 &GetByIdArgs {
990 id: pull.repo_id.clone(),
991 viewer,
992 },
993 )
994 .await?;
995 let (Outcome::Ok(repo), Some(source)) = (crate::retired::unless_archived(repo), pull.fork.clone()) else {
996 return Ok(Advance::None);
997 };
998 let issue = match pull.issue {
999 Some(number) => self.issue(&pull.repo_id, number).await?,
1000 None => None,
1001 };
1002 let behind = self.is_behind(&repo.id, &pull).await?;
1003 let Some((lifecycle, next)) = self.assess(&pull, &issue, behind).await? else {
1004 return Ok(Advance::None);
1005 };
1006
1007 if matches!(next, Next::Merge) {
1008 self.merge_by_policy(&repo, &pull).await?;
1009 return Ok(Advance::None);
1010 }
1011 let (step, minutes) = match &next {
1012 Next::Wait | Next::Merge => return Ok(Advance::None),
1013 Next::Review => ("review", REVIEW_MINUTES),
1014 Next::Revise(_) => ("revision", REVISION_MINUTES),
1015 Next::CatchUp => ("catch_up", CATCH_UP_MINUTES),
1016 };
1017 let feedback = match &next {
1018 Next::Revise(feedback) => self.feedback(&pull, feedback).await?,
1019 Next::CatchUp => self.conflict_note(&pull, &repo.default_branch).await?,
1020 _ => String::new(),
1021 };
1022 if !self
1023 .claim(&pull.id, step, minutes, matches!(next, Next::Revise(_)))
1024 .await?
1025 {
1026 return Ok(Advance::None);
1027 }
1028 // Said in the conversation, so nobody has to wonder why a review or
1029 // a new commit appeared.
1030 let told = match &next {
1031 Next::Review => {
1032 self.db
1033 .prepare(
1034 "UPDATE pulls SET reviewers = json_insert(reviewers, '$[#]', ?1)
1035 WHERE id = ?2 AND NOT EXISTS (
1036 SELECT 1 FROM json_each(pulls.reviewers) WHERE json_each.value = ?1)",
1037 )
1038 .bind(&[AGENT_NAME.into(), pull.id.as_str().into()])?
1039 .run()
1040 .await?;
1041 "requested a review from g1t-agent".to_owned()
1042 }
1043 Next::Revise(Feedback::FailedChecks) => {
1044 "sent g1t-agent back to fix what failed in the merge queue".to_owned()
1045 }
1046 Next::Revise(Feedback::FailedWorkflows) => {
1047 "sent g1t-agent back to fix the failed checks".to_owned()
1048 }
1049 Next::Revise(Feedback::Review(_)) => {
1050 "sent g1t-agent back to address the review".to_owned()
1051 }
1052 _ => format!(
1053 "asked g1t-agent to bring this up to date with {}",
1054 repo.default_branch
1055 ),
1056 };
1057 self.note(
1058 &pull.repo_id,
1059 pull.number,
1060 (POLICY_ACTOR_ID, POLICY_ACTOR_NAME),
1061 &told,
1062 )
1063 .await?;
1064 let job = LifecycleJob {
1065 pull_id: pull.id,
1066 repo: RepoPath {
1067 namespace: repo.namespace,
1068 name: repo.name,
1069 },
1070 number: pull.number,
1071 author: pull.author,
1072 source,
1073 branch: None,
1074 default_branch: repo.default_branch,
1075 title: pull.title,
1076 description: pull.body.unwrap_or_default(),
1077 issue,
1078 feedback,
1079 round: lifecycle.revisions + 1,
1080 };
1081 Ok(match next {
1082 Next::Review => Advance::Review { job },
1083 Next::Revise(_) => Advance::Revise { job },
1084 Next::CatchUp => Advance::CatchUp { job },
1085 Next::Wait | Next::Merge => Advance::None,
1086 })
1087 }
1088
1089 /// Lands a pull request that is ready, on the authority of the
1090 /// repository's settings instead of a person's click.
1091 async fn merge_by_policy(&self, repo: &Repo, pull: &Pull) -> Result<()> {
1092 if !self.claim(&pull.id, "merge", MERGE_MINUTES, false).await? {
1093 return Ok(());
1094 }
1095 // g1t acts for the workspace whose members turned this on, with the
1096 // default base permission (Write), which merging needs.
1097 let actor = User {
1098 id: POLICY_ACTOR_ID.to_owned(),
1099 username: POLICY_ACTOR_NAME.to_owned(),
1100 verified: true,
1101 workspaces: vec![Membership::member(repo.namespace.to_lowercase())],
1102 ..User::default()
1103 };
1104 let merged = self
1105 .merge_pull(PullActionArgs {
1106 actor,
1107 repo: RepoPath {
1108 namespace: repo.namespace.clone(),
1109 name: repo.name.clone(),
1110 },
1111 number: pull.number,
1112 summary: String::new(),
1113 keep_issue_open: false,
1114 ignore_checks: false,
1115 })
1116 .await?;
1117 match merged {
1118 Outcome::Ok(_) => Ok(()),
1119 // Most likely the branch moved in the moment between: let go, and
1120 // the next look at it will catch up and try again.
1121 Outcome::Fail(failure) if failure.code == FailureCode::Conflict => {
1122 self.db
1123 .prepare(
1124 "UPDATE pulls SET working_on = NULL, working_until = NULL
1125 WHERE id = ? AND working_on = 'merge'",
1126 )
1127 .bind(&[pull.id.as_str().into()])?
1128 .run()
1129 .await?;
1130 Ok(())
1131 }
1132 Outcome::Fail(failure) => {
1133 self.stall(StallArgs {
1134 pull_id: pull.id.clone(),
1135 reason: format!("g1t could not merge this: {}", failure.message),
1136 })
1137 .await?;
1138 Ok(())
1139 }
1140 }
1141 }
1142
1143 /// Records that a merge was asked for while the pull request was
1144 /// behind, and announces it so that the runner brings it up to date.
1145 pub(crate) async fn request_landing(
1146 &self,
1147 pull: &Pull,
1148 actor: &User,
1149 keep_issue_open: bool,
1150 ) -> Result<()> {
1151 let now = now_ms();
1152 let request = serde_json::to_string(&LandRequest {
1153 actor: actor.clone(),
1154 keep_issue_open,
1155 })?;
1156 self.db
1157 .prepare(
1158 "UPDATE pulls
1159 SET land_requested = ?, land_requested_at = ?, stalled = NULL,
1160 working_on = 'catch_up', working_until = ?
1161 WHERE id = ?",
1162 )
1163 .bind(&[
1164 request.into(),
1165 rfc3339(now).into(),
1166 rfc3339(now + CATCH_UP_MINUTES * 60 * 1000).into(),
1167 pull.id.as_str().into(),
1168 ])?
1169 .run()
1170 .await?;
1171 self.publish(
1172 "pull.merge_requested",
1173 &pull.repo_id,
1174 actor,
1175 Self::pull_event(pull),
1176 )
1177 .await
1178 }
1179
1180 /// The merge waiting on a pull request, if one was asked for recently
1181 /// enough to still stand.
1182 async fn land_request(&self, pull_id: &str) -> Result<Option<LandRequest>> {
1183 let row = self.land_row(pull_id).await?;
1184 let oldest = rfc3339(now_ms().saturating_sub(CATCH_UP_MINUTES * 60 * 1000));
1185 Ok(row
1186 .filter(|row| {
1187 row.land_requested_at
1188 .as_deref()
1189 .is_some_and(|at| at >= oldest.as_str())
1190 })
1191 .and_then(|row| row.land_requested)
1192 .and_then(|request| serde_json::from_str(&request).ok()))
1193 }
1194
1195 /// Whether a merge is waiting on a pull request, and why g1t stopped
1196 /// working on it if it did.
1197 pub(crate) async fn landing_state(&self, pull_id: &str) -> Result<(bool, Option<String>)> {
1198 let stalled = self.land_row(pull_id).await?.and_then(|row| row.stalled);
1199 Ok((self.land_request(pull_id).await?.is_some(), stalled))
1200 }
1201
1202 /// The merge waiting on a pull request and why g1t stopped, as read
1203 /// for this request (prefetch.rs) or now.
1204 async fn land_row(&self, pull_id: &str) -> Result<Option<LandRow>> {
1205 if let Some(found) = self.prefetched_pull(pull_id) {
1206 return found.first::<LandRow>(Slot::Pull);
1207 }
1208 self.db
1209 .prepare("SELECT land_requested, land_requested_at, stalled FROM pulls WHERE id = ?")
1210 .bind(&[pull_id.into()])?
1211 .first::<LandRow>(None)
1212 .await
1213 }
1214
1215 async fn forget_landing(&self, pull_id: &str) -> Result<()> {
1216 self.db
1217 .prepare(
1218 "UPDATE pulls SET land_requested = NULL, land_requested_at = NULL WHERE id = ?",
1219 )
1220 .bind(&[pull_id.into()])?
1221 .run()
1222 .await?;
1223 Ok(())
1224 }
1225
1226 /// Lands a pull request whose head has just moved, if a merge of it was
1227 /// waiting for exactly that. What it was caught up to was already
1228 /// checked and reviewed apart from the merge, so the checks are not
1229 /// waited for again; a repository that wants them rerun turns on
1230 /// "require up to date", and then nothing is landed this way.
1231 pub(crate) async fn land_if_requested(&self, pull_id: &str) -> Result<()> {
1232 let Some(request) = self.land_request(pull_id).await? else {
1233 return Ok(());
1234 };
1235 self.forget_landing(pull_id).await?;
1236 let Some(pull) = self.pull_by_id(pull_id).await? else {
1237 return Ok(());
1238 };
1239 let repo: Outcome<Repo> = g1t_kit::call(
1240 &self.repos,
1241 "get_by_id",
1242 &GetByIdArgs {
1243 id: pull.repo_id.clone(),
1244 viewer: Some(request.actor.clone()),
1245 },
1246 )
1247 .await?;
1248 let Outcome::Ok(repo) = crate::retired::unless_archived(repo) else {
1249 return Ok(());
1250 };
1251 let merged = self
1252 .merge_pull(PullActionArgs {
1253 actor: request.actor,
1254 repo: RepoPath {
1255 namespace: repo.namespace,
1256 name: repo.name,
1257 },
1258 number: pull.number,
1259 summary: String::new(),
1260 keep_issue_open: request.keep_issue_open,
1261 ignore_checks: true,
1262 })
1263 .await?;
1264 if let Outcome::Fail(failure) = merged {
1265 self.stall(StallArgs {
1266 pull_id: pull.id,
1267 reason: format!(
1268 "It was brought up to date but could not be merged: {}",
1269 failure.message
1270 ),
1271 })
1272 .await?;
1273 }
1274 Ok(())
1275 }
1276
1277 /// What the runner needs to bring a pull request up to date for a merge
1278 /// that is waiting on it.
1279 pub(crate) async fn catch_up_job(&self, a: CatchUpJobArgs) -> Result<Option<LifecycleJob>> {
1280 if self.land_request(&a.pull_id).await?.is_none() {
1281 return Ok(None);
1282 }
1283 let Some(pull) = self.pull_by_id(&a.pull_id).await? else {
1284 return Ok(None);
1285 };
1286 // Its author can read both the repository and the pull request's source.
1287 let repo: Outcome<Repo> = g1t_kit::call(
1288 &self.repos,
1289 "get_by_id",
1290 &GetByIdArgs {
1291 id: pull.repo_id.clone(),
1292 viewer: self.author_viewer(&pull).await?,
1293 },
1294 )
1295 .await?;
1296 let Outcome::Ok(repo) = crate::retired::unless_archived(repo) else {
1297 return Ok(None);
1298 };
1299 let path = RepoPath {
1300 namespace: repo.namespace,
1301 name: repo.name,
1302 };
1303 let issue = match pull.issue {
1304 Some(number) => self.issue(&pull.repo_id, number).await?,
1305 None => None,
1306 };
1307 let feedback = self.conflict_note(&pull, &repo.default_branch).await?;
1308 Ok(Some(LifecycleJob {
1309 pull_id: pull.id,
1310 source: pull.fork.unwrap_or_else(|| path.clone()),
1311 repo: path,
1312 number: pull.number,
1313 author: pull.author,
1314 branch: pull.branch,
1315 default_branch: repo.default_branch,
1316 title: pull.title,
1317 description: pull.body.unwrap_or_default(),
1318 issue,
1319 feedback,
1320 round: 0,
1321 }))
1322 }
1323
1324 /// For an agent catching up: the files g1t already knows conflict, so
1325 /// it reads them first. Empty when none are known.
1326 pub(crate) async fn conflict_note(&self, pull: &Pull, default_branch: &str) -> Result<String> {
1327 Ok(match self.conflicting_files(pull).await? {
1328 Some(files) if !files.is_empty() => format!(
1329 "g1t found ahead of time that merging {default_branch} into this pull request conflicts in these files: {}.",
1330 files.join(", ")
1331 ),
1332 _ => String::new(),
1333 })
1334 }
1335
1336 pub(crate) async fn stall(&self, a: StallArgs) -> Result<bool> {
1337 self.db
1338 .prepare(
1339 "UPDATE pulls
1340 SET stalled = ?, working_on = NULL, working_until = NULL,
1341 land_requested = NULL, land_requested_at = NULL
1342 WHERE id = ? AND status = 'open'",
1343 )
1344 .bind(&[a.reason.trim().into(), a.pull_id.as_str().into()])?
1345 .run()
1346 .await?;
1347 self.db
1348 .prepare("UPDATE pulls SET stage = 'needs_you', stage_detail = ? WHERE id = ?")
1349 .bind(&[a.reason.trim().into(), a.pull_id.into()])?
1350 .run()
1351 .await?;
1352 Ok(true)
1353 }
1354
1355 pub(crate) async fn managed_pulls(&self, a: ManagedPullsArgs) -> Result<Vec<String>> {
1356 let rows = self
1357 .db
1358 .prepare(
1359 "SELECT id AS value FROM pulls
1360 WHERE status = 'open' AND managed = 1 AND stalled IS NULL
1361 AND (?1 IS NULL OR repo_id = ?1)
1362 ORDER BY updated_at DESC LIMIT ?2",
1363 )
1364 .bind(&[
1365 a.repo_id.map_or(JsValue::NULL, JsValue::from),
1366 MANAGED_PAGE.into(),
1367 ])?
1368 .all()
1369 .await?
1370 .results::<ValueRow>()?;
1371 Ok(rows.into_iter().map(|row| row.value).collect())
1372 }
1373}
1374
1375#[cfg(test)]
1376mod tests {
1377 use super::*;
1378
1379 const MAX_REVISIONS: u32 = 2;
1380
1381 #[test]
1382 fn approvals_the_repository_wants_are_waited_for() {
1383 let short = || Facts {
1384 review: reviewed(Some(Verdict::Approve)),
1385 approvals_missing: Some("This repository requires 1 approving review.".to_owned()),
1386 ..facts()
1387 };
1388 let (lifecycle, next) = decide(short());
1389 assert_eq!(lifecycle.stage, Stage::NeedsYou);
1390 assert_eq!(
1391 lifecycle.detail,
1392 "This repository requires 1 approving review."
1393 );
1394 assert!(matches!(next, Next::Wait));
1395 // Not even a repository that merges by itself merges without them.
1396 let automatic = Facts {
1397 auto_merge: true,
1398 ..short()
1399 };
1400 assert_eq!(outcome(automatic), (Stage::NeedsYou, "wait"));
1401 }
1402
1403 #[test]
1404 fn a_repository_can_leave_review_to_people() {
1405 let unreviewed = Facts {
1406 agent_review: false,
1407 ..facts()
1408 };
1409 assert_eq!(outcome(unreviewed), (Stage::Ready, "wait"));
1410 let failing = Facts {
1411 agent_review: false,
1412 check_status: Some(CheckStatus::Failed),
1413 ..facts()
1414 };
1415 assert_eq!(outcome(failing), (Stage::Revising, "revise for checks"));
1416 }
1417
1418 #[test]
1419 fn a_repository_sets_how_often_the_author_is_sent_back() {
1420 let never = Facts {
1421 max_revisions: 0,
1422 check_status: Some(CheckStatus::Failed),
1423 ..facts()
1424 };
1425 assert_eq!(outcome(never), (Stage::NeedsYou, "wait"));
1426 }
1427
1428 /// Checks on a head commit: `(context, state)` statuses, against the
1429 /// required check names.
1430 fn checks(statuses: &[(&str, &str)], required: &[&str]) -> WorkflowFacts {
1431 let statuses: Vec<CommitStatus> = statuses
1432 .iter()
1433 .map(|(context, state)| CommitStatus {
1434 context: (*context).to_owned(),
1435 state: (*state).to_owned(),
1436 description: None,
1437 target_url: None,
1438 updated_at: String::new(),
1439 })
1440 .collect();
1441 let required: Vec<String> = required.iter().map(|name| (*name).to_owned()).collect();
1442 WorkflowFacts::of(&statuses, &required)
1443 }
1444
1445 /// A pull request that is ready for review, whose required check
1446 /// passed, and nothing else yet.
1447 fn facts() -> Facts {
1448 Facts {
1449 draft: false,
1450 stalled: None,
1451 working_on: None,
1452 check_status: None,
1453 review_pending: false,
1454 revisions: 0,
1455 review: None,
1456 behind: false,
1457 conflicting: false,
1458 auto_merge: false,
1459 require_up_to_date: false,
1460 agent_review: true,
1461 max_revisions: MAX_REVISIONS,
1462 approvals_missing: None,
1463 person_request: None,
1464 queued: None,
1465 workflows: checks(&[("CI / pull_request", "success")], &["CI"]),
1466 low_confidence: None,
1467 }
1468 }
1469
1470 #[test]
1471 fn failed_checks_send_the_agent_back_and_running_ones_wait() {
1472 let failed = Facts {
1473 workflows: checks(&[("CI / pull_request", "failure")], &["CI"]),
1474 ..facts()
1475 };
1476 let (lifecycle, next) = decide(failed);
1477 assert!(matches!(next, Next::Revise(Feedback::FailedWorkflows)));
1478 assert!(lifecycle.detail.contains("CI / pull_request failed"));
1479 let running = Facts {
1480 workflows: checks(&[("CI / pull_request", "pending")], &["CI"]),
1481 ..facts()
1482 };
1483 let (lifecycle, next) = decide(running);
1484 assert!(matches!(next, Next::Wait));
1485 assert!(lifecycle.detail.contains("Waiting for CI / pull_request"));
1486 }
1487
1488 #[test]
1489 fn a_check_the_branch_does_not_require_is_fixed_but_does_not_hold_it_for_ever() {
1490 // Lint is not required: the agent is still sent back to fix it...
1491 let lint = checks(&[("CI / pull_request", "success"), ("Lint / pull_request", "failure")], &["CI"]);
1492 let failing = Facts { workflows: lint.clone(), ..facts() };
1493 assert_eq!(outcome(failing), (Stage::Revising, "revise for workflows"));
1494 // ...but once it is out of revisions, Lint no longer holds it.
1495 let exhausted = Facts {
1496 workflows: lint,
1497 revisions: MAX_REVISIONS,
1498 review: reviewed(Some(Verdict::Approve)),
1499 ..facts()
1500 };
1501 assert_eq!(outcome(exhausted), (Stage::Ready, "wait"));
1502 // A required check that still fails asks a person.
1503 let required = Facts {
1504 workflows: checks(&[("CI / pull_request", "failure")], &["CI"]),
1505 revisions: MAX_REVISIONS,
1506 ..facts()
1507 };
1508 let (lifecycle, next) = decide(required);
1509 assert_eq!(lifecycle.stage, Stage::NeedsYou);
1510 assert_eq!(lifecycle.detail, "The required check CI still fails after the agent revised twice.");
1511 assert!(matches!(next, Next::Wait));
1512 }
1513
1514 #[test]
1515 fn a_required_check_that_has_not_reported_is_waited_for() {
1516 let missing = Facts {
1517 workflows: checks(&[("CI / pull_request", "success")], &["CI", "Deploy"]),
1518 review: reviewed(Some(Verdict::Approve)),
1519 auto_merge: true,
1520 ..facts()
1521 };
1522 let (lifecycle, next) = decide(missing);
1523 assert_eq!(lifecycle.stage, Stage::Checking);
1524 assert_eq!(lifecycle.detail, "Waiting for the required check Deploy to report on its latest commit.");
1525 assert!(matches!(next, Next::Wait), "auto-merge must not land it");
1526 }
1527
1528 #[test]
1529 fn a_queued_pull_request_waits_in_the_queue() {
1530 let queued = Facts {
1531 review: reviewed(Some(Verdict::Approve)),
1532 queued: Some((QueueState::Testing, vec![12, 14])),
1533 auto_merge: true,
1534 ..facts()
1535 };
1536 let (lifecycle, next) = decide(queued);
1537 assert_eq!(lifecycle.stage, Stage::Queued);
1538 assert!(lifecycle.detail.contains("#12, #14"));
1539 assert!(matches!(next, Next::Wait));
1540 }
1541
1542 fn asked_by_a_person() -> Option<PersonRequest> {
1543 Some(PersonRequest {
1544 author_id: "usr_reviewer".to_owned(),
1545 author_name: "g1t-reviewer".to_owned(),
1546 created_at: "2026-10-02T11:00:00.000Z".to_owned(),
1547 })
1548 }
1549
1550 #[test]
1551 fn a_person_asking_for_changes_sends_the_agent_back() {
1552 let asked = Facts {
1553 review: reviewed(Some(Verdict::Approve)),
1554 approvals_missing: Some("A reviewer has asked for changes.".to_owned()),
1555 person_request: asked_by_a_person(),
1556 ..facts()
1557 };
1558 let (lifecycle, _) = decide(Facts {
1559 person_request: asked_by_a_person(),
1560 ..facts()
1561 });
1562 assert!(lifecycle.detail.starts_with("g1t-reviewer asked for changes"));
1563 assert_eq!(outcome(asked), (Stage::Revising, "revise for a person"));
1564 }
1565
1566 #[test]
1567 fn a_person_is_asked_once_the_revisions_run_out() {
1568 let exhausted = Facts {
1569 person_request: asked_by_a_person(),
1570 revisions: MAX_REVISIONS,
1571 ..facts()
1572 };
1573 assert_eq!(outcome(exhausted), (Stage::NeedsYou, "wait"));
1574 }
1575
1576 fn reviewed(verdict: Option<Verdict>) -> Option<FinishedReview> {
1577 Some(FinishedReview {
1578 finished_at: "2026-10-02T10:00:00.000Z".to_owned(),
1579 verdict,
1580 })
1581 }
1582
1583 /// The stage, and a word for the step to take.
1584 fn outcome(facts: Facts) -> (Stage, &'static str) {
1585 let (lifecycle, next) = decide(facts);
1586 let step = match next {
1587 Next::Wait => "wait",
1588 Next::Review => "review",
1589 Next::Revise(Feedback::FailedChecks) => "revise for checks",
1590 Next::Revise(Feedback::FailedWorkflows) => "revise for workflows",
1591 Next::Revise(Feedback::Review(_)) => "revise for review",
1592 Next::Revise(Feedback::Person(_)) => "revise for a person",
1593 Next::CatchUp => "catch up",
1594 Next::Merge => "merge",
1595 };
1596 (lifecycle.stage, step)
1597 }
1598
1599 #[test]
1600 fn nothing_is_started_while_the_agent_is_still_working() {
1601 let draft = Facts {
1602 draft: true,
1603 check_status: None,
1604 ..facts()
1605 };
1606 assert_eq!(outcome(draft), (Stage::Working, "wait"));
1607 }
1608
1609 #[test]
1610 fn checks_come_before_review() {
1611 let unchecked = Facts {
1612 workflows: checks(&[], &["CI"]),
1613 ..facts()
1614 };
1615 assert_eq!(outcome(unchecked), (Stage::Checking, "wait"));
1616 let running = Facts {
1617 workflows: checks(&[("CI / pull_request", "pending")], &["CI"]),
1618 ..facts()
1619 };
1620 assert_eq!(outcome(running), (Stage::Checking, "wait"));
1621 assert_eq!(outcome(facts()), (Stage::Reviewing, "review"));
1622 }
1623
1624 #[test]
1625 fn a_branch_that_requires_no_checks_goes_straight_to_review() {
1626 let unchecked = Facts {
1627 workflows: WorkflowFacts::default(),
1628 ..facts()
1629 };
1630 assert_eq!(outcome(unchecked), (Stage::Reviewing, "review"));
1631 }
1632
1633 #[test]
1634 fn failing_in_the_merge_queue_sends_the_author_back() {
1635 let failed = Facts {
1636 check_status: Some(CheckStatus::Failed),
1637 ..facts()
1638 };
1639 let (lifecycle, next) = decide(failed);
1640 assert_eq!(lifecycle.detail, "It failed in the merge queue. The agent is being sent back to fix it.");
1641 assert!(matches!(next, Next::Revise(Feedback::FailedChecks)));
1642 }
1643
1644 #[test]
1645 fn checks_that_could_not_run_are_a_persons_problem() {
1646 let errored = Facts {
1647 check_status: Some(CheckStatus::Errored),
1648 ..facts()
1649 };
1650 assert_eq!(outcome(errored), (Stage::NeedsYou, "wait"));
1651 }
1652
1653 #[test]
1654 fn a_review_asking_for_changes_sends_the_author_back() {
1655 let changes = Facts {
1656 review: reviewed(Some(Verdict::RequestChanges)),
1657 ..facts()
1658 };
1659 assert_eq!(outcome(changes), (Stage::Revising, "revise for review"));
1660 }
1661
1662 #[test]
1663 fn the_author_is_sent_back_only_so_many_times() {
1664 let failing = Facts {
1665 check_status: Some(CheckStatus::Failed),
1666 revisions: MAX_REVISIONS,
1667 ..facts()
1668 };
1669 assert_eq!(outcome(failing), (Stage::NeedsYou, "wait"));
1670 let unconvinced = Facts {
1671 review: reviewed(Some(Verdict::RequestChanges)),
1672 revisions: MAX_REVISIONS,
1673 ..facts()
1674 };
1675 assert_eq!(outcome(unconvinced), (Stage::NeedsYou, "wait"));
1676 // One short of the limit still gets another go.
1677 let once = Facts {
1678 check_status: Some(CheckStatus::Failed),
1679 revisions: MAX_REVISIONS - 1,
1680 ..facts()
1681 };
1682 assert_eq!(outcome(once), (Stage::Revising, "revise for checks"));
1683 }
1684
1685 #[test]
1686 fn a_review_that_could_not_be_written_is_not_retried() {
1687 let broken = Facts {
1688 review: reviewed(None),
1689 ..facts()
1690 };
1691 assert_eq!(outcome(broken), (Stage::NeedsYou, "wait"));
1692 }
1693
1694 #[test]
1695 fn being_behind_only_holds_a_change_up_where_the_repository_says_so() {
1696 let behind = || Facts {
1697 review: reviewed(Some(Verdict::Approve)),
1698 behind: true,
1699 ..facts()
1700 };
1701 // By default it is ready as it is; merging brings it up to date.
1702 assert_eq!(outcome(behind()), (Stage::Ready, "wait"));
1703 let strict = Facts {
1704 require_up_to_date: true,
1705 ..behind()
1706 };
1707 assert_eq!(outcome(strict), (Stage::CatchingUp, "catch up"));
1708 let current = Facts {
1709 review: reviewed(Some(Verdict::Approve)),
1710 ..facts()
1711 };
1712 assert_eq!(outcome(current), (Stage::Ready, "wait"));
1713 }
1714
1715 #[test]
1716 fn a_ready_change_lands_by_itself_only_where_the_repository_says_so() {
1717 let ready = || Facts {
1718 review: reviewed(Some(Verdict::Approve)),
1719 ..facts()
1720 };
1721 assert_eq!(outcome(ready()), (Stage::Ready, "wait"));
1722 let automatic = Facts {
1723 auto_merge: true,
1724 ..ready()
1725 };
1726 assert_eq!(outcome(automatic), (Stage::Ready, "merge"));
1727 // One that is behind is merged too: merging brings it up to date.
1728 let behind = Facts {
1729 auto_merge: true,
1730 behind: true,
1731 ..ready()
1732 };
1733 assert_eq!(outcome(behind), (Stage::Ready, "merge"));
1734 // Unless the repository wants it caught up and checked again first.
1735 let strict = Facts {
1736 auto_merge: true,
1737 behind: true,
1738 require_up_to_date: true,
1739 ..ready()
1740 };
1741 assert_eq!(outcome(strict), (Stage::CatchingUp, "catch up"));
1742 // Nothing short of approved is merged, whatever the setting.
1743 let failing = Facts {
1744 auto_merge: true,
1745 check_status: Some(CheckStatus::Failed),
1746 ..ready()
1747 };
1748 assert_eq!(outcome(failing), (Stage::Revising, "revise for checks"));
1749 let unreviewed = Facts {
1750 auto_merge: true,
1751 ..facts()
1752 };
1753 assert_eq!(outcome(unreviewed), (Stage::Reviewing, "review"));
1754 }
1755
1756 #[test]
1757 fn a_conflict_found_ahead_of_time_is_resolved_before_merging() {
1758 let conflicting = || Facts {
1759 review: reviewed(Some(Verdict::Approve)),
1760 behind: true,
1761 conflicting: true,
1762 ..facts()
1763 };
1764 // Even where the repository would merge one that is merely behind.
1765 assert_eq!(outcome(conflicting()), (Stage::CatchingUp, "catch up"));
1766 let automatic = Facts {
1767 auto_merge: true,
1768 ..conflicting()
1769 };
1770 assert_eq!(outcome(automatic), (Stage::CatchingUp, "catch up"));
1771 // Failed checks come first: a revision merges the branch in too.
1772 let failing = Facts {
1773 check_status: Some(CheckStatus::Failed),
1774 ..conflicting()
1775 };
1776 assert_eq!(outcome(failing), (Stage::Revising, "revise for checks"));
1777 }
1778
1779 #[test]
1780 fn catching_up_reruns_the_checks_but_not_the_review() {
1781 // The merge moved the head, so the checks are waited for again.
1782 let merged_in = Facts {
1783 review: reviewed(Some(Verdict::Approve)),
1784 workflows: checks(&[], &["CI"]),
1785 ..facts()
1786 };
1787 assert_eq!(outcome(merged_in), (Stage::Checking, "wait"));
1788 }
1789
1790 #[test]
1791 fn a_step_under_way_is_not_started_again() {
1792 for (step, stage) in [
1793 ("review", Stage::Reviewing),
1794 ("revision", Stage::Revising),
1795 ("catch_up", Stage::CatchingUp),
1796 ] {
1797 let busy = Facts {
1798 working_on: Some(step.to_owned()),
1799 // Whatever else is true, the step in hand comes first.
1800 check_status: Some(CheckStatus::Failed),
1801 ..facts()
1802 };
1803 assert_eq!(outcome(busy), (stage, "wait"));
1804 }
1805 let asked = Facts {
1806 review_pending: true,
1807 ..facts()
1808 };
1809 assert_eq!(outcome(asked), (Stage::Reviewing, "wait"));
1810 }
1811
1812 #[test]
1813 fn a_low_confidence_change_waits_for_a_person_instead_of_merging() {
1814 let held = || Facts {
1815 review: reviewed(Some(Verdict::Approve)),
1816 auto_merge: true,
1817 low_confidence: Some("tests not added, 3 revisions".to_owned()),
1818 ..facts()
1819 };
1820 let (lifecycle, next) = decide(held());
1821 assert_eq!(lifecycle.stage, Stage::NeedsYou);
1822 assert!(matches!(next, Next::Wait), "auto-merge must not land it");
1823 assert_eq!(
1824 lifecycle.detail,
1825 "The agent's confidence in this change is low (tests not added, 3 revisions). This repository asks a person before merging it: approve it to let it land, or ask for changes."
1826 );
1827 // Without auto-merge it needs someone too, and says why.
1828 assert_eq!(outcome(Facts { auto_merge: false, ..held() }), (Stage::NeedsYou, "wait"));
1829 // Not held (the setting is off, or a person approved): it lands.
1830 assert_eq!(outcome(Facts { low_confidence: None, ..held() }), (Stage::Ready, "merge"));
1831 // It holds only a change that is otherwise ready: what comes first,
1832 // such as failed checks, is still dealt with first.
1833 let failing = Facts {
1834 check_status: Some(CheckStatus::Failed),
1835 ..held()
1836 };
1837 assert_eq!(outcome(failing), (Stage::Revising, "revise for checks"));
1838 let unapproved = Facts {
1839 approvals_missing: Some("This repository requires 1 approving review.".to_owned()),
1840 ..held()
1841 };
1842 assert_eq!(decide(unapproved).0.detail, "This repository requires 1 approving review.");
1843 }
1844
1845 #[test]
1846 fn once_stopped_it_stays_stopped() {
1847 let (lifecycle, next) = decide(Facts {
1848 stalled: Some("The agent could not catch up.".to_owned()),
1849 review: reviewed(Some(Verdict::Approve)),
1850 behind: true,
1851 ..facts()
1852 });
1853 assert_eq!(lifecycle.stage, Stage::NeedsYou);
1854 assert_eq!(lifecycle.detail, "The agent could not catch up.");
1855 assert!(matches!(next, Next::Wait));
1856 }
1857}