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