g1t/services/work/src/lifecycle.rs

1,512 lines55,153 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents as a team: lifecycle, merge queue, billing and a new shell1//! Seeing a pull request through. Once a g1t agent has made a change, g1t
2//! takes each remaining step itself: the acceptance checks, a review by
3//! another agent, sending the author back to address what either found,
4//! and catching up when the branch it would land on has moved. It stops
5//! when the pull request is ready for a person to merge, or when it has
6//! tried and a person has to decide.
7//!
8//! This service decides what the next step is and claims it. The runner
9//! service asks, on every event that could change the answer, and carries
10//! the step out in a sandbox.
11
12use g1t_contracts::events::PullEvent;
13use g1t_contracts::repos::{GetByIdArgs, Repo, RepoPath};
14use g1t_contracts::time::rfc3339;
15use g1t_contracts::work::*;
16use g1t_contracts::{FailureCode, Membership, Outcome, Role, User, Viewer};
17use g1t_kit::now_ms;
18use std::collections::HashMap;
19
20use serde::Deserialize;
21use worker::Result;
22use worker::wasm_bindgen::JsValue;
23
24use crate::Work;
25use crate::reviews::{AGENT_ID, AGENT_NAME};
GitHub Actions on g1t, part two: running workflows26use crate::statuses::{self, WorkflowFacts};
Agents as a team: lifecycle, merge queue, billing and a new shell27use crate::rows::ValueRow;
28
29/// How long a claimed step is waited for before it may be taken again.
30const REVIEW_MINUTES: u64 = 20;
31const REVISION_MINUTES: u64 = 60;
32const CATCH_UP_MINUTES: u64 = 30;
33const MERGE_MINUTES: u64 = 2;
34/// Who a merge made by a repository's settings is attributed to. Not an
35/// account: `g1t` cannot be registered.
A stalled pull request picks back up when its workflows pass36pub(crate) const POLICY_ACTOR_ID: &str = "g1t_policy";
37pub(crate) const POLICY_ACTOR_NAME: &str = "g1t";
Agents as a team: lifecycle, merge queue, billing and a new shell38/// How much of a failed check's output the author is shown.
39const MAX_CHECK_OUTPUT_CHARS: usize = 4_000;
40const MANAGED_PAGE: u32 = 200;
41
42/// The part of a pull request's row that tracks its lifecycle.
43#[derive(Deserialize)]
44struct Progress {
45 managed: u8,
46 revisions: u32,
47 revised_at: Option<String>,
48 working_on: Option<String>,
49 working_until: Option<String>,
50 stalled: Option<String>,
51}
52
53/// A merge asked for while the pull request was behind, as stored.
54#[derive(serde::Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56struct LandRequest {
57 actor: User,
58 keep_issue_open: bool,
59}
60
61#[derive(Deserialize)]
62struct LandRow {
63 land_requested: Option<String>,
64 land_requested_at: Option<String>,
65 stalled: Option<String>,
66}
67
68#[derive(Deserialize)]
69struct FinishedReview {
70 finished_at: String,
71 verdict: Option<Verdict>,
72}
73
74#[derive(Deserialize)]
75struct ReviewNote {
76 body: String,
77 path: Option<String>,
78 line: Option<u32>,
79}
80
81/// What the author is being sent back to address.
82pub(crate) enum Feedback {
83 FailedChecks,
GitHub Actions on g1t, part two: running workflows84 /// Workflows that failed on its head.
85 FailedWorkflows,
Agents as a team: lifecycle, merge queue, billing and a new shell86 /// The review that finished at this time.
87 Review(String),
88 /// What a person who asked for changes wrote since the last revision.
89 Person(PersonRequest),
90}
91
92/// A person's request for changes that still stands: their latest verdict,
93/// made after the agent last revised.
94#[derive(Clone, Debug, Deserialize)]
95pub(crate) struct PersonRequest {
96 author_id: String,
97 author_name: String,
98 created_at: String,
99}
100
101/// What should happen next, if it is g1t's turn.
102pub(crate) enum Next {
103 /// A step is under way, or it is a person's turn.
104 Wait,
105 Review,
106 Revise(Feedback),
107 CatchUp,
108 /// Land it, because the repository says ready pull requests land.
109 Merge,
110}
111
112/// Whether g1t made this pull request, and so sees it through.
113pub(crate) fn made_by_g1t(pull: &Pull) -> bool {
114 pull.runtime == Runtime::Hosted && pull.agent == AGENT_NAME && pull.fork.is_some()
115}
116
117fn at(stage: Stage, detail: impl Into<String>, revisions: u32) -> Lifecycle {
118 Lifecycle {
119 stage,
120 detail: detail.into(),
121 revisions,
122 }
123}
124
125fn times(count: u32) -> String {
126 match count {
127 1 => "once".to_owned(),
128 2 => "twice".to_owned(),
129 count => format!("{count} times"),
130 }
131}
132
133/// Everything the next step depends on.
134struct Facts {
135 /// Still being made: not yet marked ready for review.
136 draft: bool,
137 /// Why g1t stopped, if it has.
138 stalled: Option<String>,
139 /// The step under way, if one was claimed and is still being waited for.
140 working_on: Option<String>,
141 check_status: Option<CheckStatus>,
142 /// A review someone asked for is being written.
143 review_pending: bool,
144 /// Whether the issue has acceptance checks at all.
145 has_checks: bool,
146 revisions: u32,
147 /// The latest finished review of the change as it is now.
148 review: Option<FinishedReview>,
149 /// Whether the branch it would land on has moved without it.
150 behind: bool,
151 /// Whether the repository lands a ready pull request by itself.
152 auto_merge: bool,
153 /// Whether the repository refuses to merge one that is behind.
154 require_up_to_date: bool,
155 /// Whether a second agent reviews it without being asked.
156 agent_review: bool,
157 /// How many times the author may be sent back.
158 max_revisions: u32,
159 /// What the repository's approval rule still wants, if anything.
160 approvals_missing: Option<String>,
161 /// A person asked for changes since the agent last revised.
162 person_request: Option<PersonRequest>,
163 /// Its place in the merge queue, and what is ahead of it there.
164 queued: Option<(QueueState, Vec<u32>)>,
GitHub Actions on g1t, part two: running workflows165 /// What the workflows run on its head say.
166 workflows: WorkflowFacts,
Agents as a team: lifecycle, merge queue, billing and a new shell167}
168
169/// Where a pull request stands, and the step to take if it is g1t's turn.
170///
171/// The order is: nothing while a step is under way; a person asking for
172/// changes is answered first; the checks must pass; then a review must
173/// approve; then it must be up to date. A person's or an agent's request
174/// for changes, or a failed check, sends the author back, a limited number
175/// of times, after which a person is asked.
176fn decide(facts: Facts) -> (Lifecycle, Next) {
177 let revisions = facts.revisions;
178 let wait = |stage, detail: &str| (at(stage, detail, revisions), Next::Wait);
179 let exhausted = revisions >= facts.max_revisions;
180
181 if facts.draft {
182 return wait(Stage::Working, "A g1t agent is making the change.");
183 }
184 if let Some(reason) = &facts.stalled {
185 return wait(Stage::NeedsYou, reason);
186 }
187 if let Some((state, ahead)) = &facts.queued {
188 let named = ahead.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", ");
189 let detail = match (state, ahead.is_empty()) {
190 (QueueState::Testing, true) => "In the merge queue: being tested on the default branch as it is.".to_owned(),
191 (QueueState::Testing, false) => {
192 format!("In the merge queue: being tested together with {named}, ahead of it.")
193 }
194 (QueueState::Passed, true) => "Passed in the merge queue. Landing.".to_owned(),
195 (QueueState::Passed, false) => {
196 format!("Passed in the merge queue together with {named}. It lands once they have.")
197 }
198 _ => "In the merge queue, waiting for its turn to be tested.".to_owned(),
199 };
200 return wait(Stage::Queued, &detail);
201 }
202 match facts.working_on.as_deref() {
203 Some("revision") => {
204 return wait(
205 Stage::Revising,
206 "The agent is addressing what the checks or the review found.",
207 );
208 }
209 Some("catch_up") => {
210 return wait(
211 Stage::CatchingUp,
212 "The agent is merging in the branch this will land on, which has moved.",
213 );
214 }
215 Some("merge") => return wait(Stage::Ready, "Merging."),
216 Some(_) => return wait(Stage::Reviewing, "A g1t agent is reviewing the change."),
217 None => {}
218 }
219 if matches!(
220 facts.check_status,
221 Some(CheckStatus::Queued | CheckStatus::Running)
222 ) {
223 return wait(Stage::Checking, "The acceptance checks are running.");
224 }
225 if facts.review_pending {
226 return wait(Stage::Reviewing, "A g1t agent is reviewing the change.");
227 }
228 // A person asked for changes: the agent makes them, as it would for a
229 // review it asked for, before anything else.
230 if let Some(request) = &facts.person_request {
231 if exhausted {
232 return wait(
233 Stage::NeedsYou,
234 &format!(
235 "{} asked for changes, and the agent has already revised {}.",
236 request.author_name,
237 times(revisions)
238 ),
239 );
240 }
241 return (
242 at(
243 Stage::Revising,
244 format!(
245 "{} asked for changes. The agent is being sent back to make them.",
246 request.author_name
247 ),
248 revisions,
249 ),
250 Next::Revise(Feedback::Person(request.clone())),
251 );
252 }
253
Sidebar: the panels really slide254 // An issue's checks, and any failure recorded as one, such as the merge
255 // queue taking the pull request out.
256 if facts.has_checks || matches!(facts.check_status, Some(CheckStatus::Failed | CheckStatus::Errored)) {
Agents as a team: lifecycle, merge queue, billing and a new shell257 match facts.check_status {
258 Some(CheckStatus::Passed) => {}
259 Some(CheckStatus::Failed) if exhausted => {
260 return wait(
261 Stage::NeedsYou,
262 &format!(
263 "The acceptance checks still fail after the agent revised {}.",
264 times(revisions)
265 ),
266 );
267 }
268 Some(CheckStatus::Failed) => {
269 return (
270 at(
271 Stage::Revising,
272 "The acceptance checks failed. The agent is being sent back to fix them.",
273 revisions,
274 ),
275 Next::Revise(Feedback::FailedChecks),
276 );
277 }
278 Some(CheckStatus::Errored) => {
279 return wait(Stage::NeedsYou, "The acceptance checks could not be run.");
280 }
281 _ => {
282 return wait(
283 Stage::Checking,
284 "Waiting for the acceptance checks to start.",
285 );
286 }
287 }
288 }
289
GitHub Actions on g1t, part two: running workflows290 // Its workflows, like its checks, must pass.
291 if !facts.workflows.failed.is_empty() {
292 let failed = statuses::list(&facts.workflows.failed);
293 if exhausted {
294 return wait(
295 Stage::NeedsYou,
296 &format!("{failed} still fails after the agent revised {}.", times(revisions)),
297 );
298 }
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 !facts.workflows.pending.is_empty() {
309 return wait(
310 Stage::Checking,
311 &format!("Waiting for {} to finish.", statuses::list(&facts.workflows.pending)),
312 );
313 }
314
Agents as a team: lifecycle, merge queue, billing and a new shell315 // A second agent reviews it, unless the repository leaves review to people.
316 if facts.agent_review {
317 match facts.review {
318 None => {
319 return (
320 at(
321 Stage::Reviewing,
322 "A g1t agent is about to review the change.",
323 revisions,
324 ),
325 Next::Review,
326 );
327 }
328 Some(FinishedReview { verdict: None, .. }) => {
329 return wait(Stage::NeedsYou, "The review could not be completed.");
330 }
331 Some(FinishedReview {
332 verdict: Some(Verdict::RequestChanges),
333 ..
334 }) if exhausted => {
335 return wait(
336 Stage::NeedsYou,
337 &format!(
338 "The review still asks for changes after the agent revised {}.",
339 times(revisions)
340 ),
341 );
342 }
343 Some(FinishedReview {
344 verdict: Some(Verdict::RequestChanges),
345 finished_at,
346 }) => {
347 return (
348 at(
349 Stage::Revising,
350 "The review asked for changes. The agent is being sent back to make them.",
351 revisions,
352 ),
353 Next::Revise(Feedback::Review(finished_at)),
354 );
355 }
356 Some(FinishedReview {
357 verdict: Some(Verdict::Approve),
358 ..
359 }) => {}
360 }
361 }
362
363 // Only where the repository insists is catching up a step of its own,
364 // followed by the checks again. Elsewhere it happens as part of merging.
365 if facts.behind && facts.require_up_to_date {
366 return (
367 at(
368 Stage::CatchingUp,
369 "The branch it will land on has moved. The agent is catching up.",
370 revisions,
371 ),
372 Next::CatchUp,
373 );
374 }
375 // The repository wants approvals this does not have yet: people's turn,
376 // so it is shown as needing someone, not as g1t still working.
377 if let Some(missing) = &facts.approvals_missing {
378 return wait(Stage::NeedsYou, missing);
379 }
380 if facts.auto_merge {
381 return (
382 at(
383 Stage::Ready,
384 "Everything this repository asks for is met. Merging, as its settings say.",
385 revisions,
386 ),
387 Next::Merge,
388 );
389 }
390 wait(
391 Stage::Ready,
392 if facts.behind {
393 "Ready to merge. Merging brings it up to date with the default branch first."
394 } else {
395 "Everything this repository asks for is met. Ready to merge."
396 },
397 )
398}
399
400impl Work {
401 /// Where a pull request stands and what g1t does next, remembered so
402 /// lists can show it without working it out again. `None` for one g1t
403 /// is not seeing through.
404 pub(crate) async fn assess(
405 &self,
406 pull: &Pull,
407 issue: &Option<Issue>,
408 behind: bool,
409 ) -> Result<Option<(Lifecycle, Next)>> {
410 let assessed = self.assess_now(pull, issue, behind).await?;
411 if let Some((lifecycle, _)) = &assessed {
412 self.remember(&pull.id, lifecycle).await?;
413 }
414 Ok(assessed)
415 }
416
417 /// Saves where a pull request stands, for [`Self::remembered`].
418 pub(crate) async fn remember(&self, pull_id: &str, lifecycle: &Lifecycle) -> Result<()> {
419 let stage = serde_json::to_value(lifecycle.stage)?;
420 self.db
421 .prepare("UPDATE pulls SET stage = ?, stage_detail = ? WHERE id = ?")
422 .bind(&[
423 stage.as_str().unwrap_or_default().into(),
424 lifecycle.detail.as_str().into(),
425 pull_id.into(),
426 ])?
427 .run()
428 .await?;
429 Ok(())
430 }
431
432 async fn assess_now(
433 &self,
434 pull: &Pull,
435 issue: &Option<Issue>,
436 behind: bool,
437 ) -> Result<Option<(Lifecycle, Next)>> {
438 if !pull.status.is_active() {
439 return Ok(None);
440 }
441 let Some(progress) = self
442 .db
443 .prepare(
444 "SELECT managed, revisions, revised_at, working_on, working_until, stalled
445 FROM pulls WHERE id = ?",
446 )
447 .bind(&[pull.id.as_str().into()])?
448 .first::<Progress>(None)
449 .await?
450 .filter(|progress| progress.managed != 0)
451 else {
452 return Ok(None);
453 };
454 let now = rfc3339(now_ms());
455 let working_on = progress
456 .working_until
457 .as_deref()
458 .is_some_and(|until| until > now.as_str())
459 .then(|| progress.working_on.clone().unwrap_or_default());
460 let review = self
461 .db
462 .prepare(
463 "SELECT finished_at, verdict FROM review_runs
464 WHERE pull_id = ? AND finished_at IS NOT NULL ORDER BY id DESC LIMIT 1",
465 )
466 .bind(&[pull.id.as_str().into()])?
467 .first::<FinishedReview>(None)
468 .await?
469 // A review of what the change was before its last revision says
470 // nothing about what it is now.
471 .filter(|review| {
472 progress
473 .revised_at
474 .as_deref()
475 .is_none_or(|revised| review.finished_at.as_str() >= revised)
476 });
477 let settings = self.settings(&pull.repo_id).await?;
478 Ok(Some(decide(Facts {
479 draft: pull.status == PullStatus::Draft,
480 stalled: progress.stalled,
481 working_on,
482 check_status: pull.check_status,
483 review_pending: self.review_pending(&pull.id).await?,
484 has_checks: issue.as_ref().is_some_and(|issue| !issue.checks.is_empty()),
485 revisions: progress.revisions,
486 review,
487 behind,
488 auto_merge: settings.auto_merge,
489 require_up_to_date: settings.require_up_to_date,
490 agent_review: settings.agent_review,
491 max_revisions: settings.max_revisions,
492 approvals_missing: self.approvals_gap(&settings, pull).await?,
493 person_request: self
494 .person_request(pull, progress.revised_at.as_deref())
495 .await?,
496 queued: self.queued_entry(&pull.id).await?,
GitHub Actions on g1t, part two: running workflows497 workflows: WorkflowFacts::of(&self.statuses(&pull.repo_id, pull.head_commit.as_deref()).await?),
Agents as a team: lifecycle, merge queue, billing and a new shell498 })))
499 }
500
501 /// The latest request for changes by a person other than the author,
502 /// if it is that person's latest verdict and came after the last
503 /// revision.
504 async fn person_request(
505 &self,
506 pull: &Pull,
507 revised_at: Option<&str>,
508 ) -> Result<Option<PersonRequest>> {
509 #[derive(Deserialize)]
510 struct Verdicts {
511 author_id: String,
512 author_name: String,
513 verdict: Verdict,
514 created_at: String,
515 }
516 let rows = self
517 .db
518 .prepare(
519 "SELECT author_id, author_name, verdict, created_at FROM comments
520 WHERE repo_id = ? AND number = ? AND verdict IS NOT NULL
521 AND author_id != ? AND author_id != ?
522 ORDER BY id",
523 )
524 .bind(&[
525 pull.repo_id.as_str().into(),
526 pull.number.into(),
527 pull.author.id.as_str().into(),
528 AGENT_ID.into(),
529 ])?
530 .all()
531 .await?
532 .results::<Verdicts>()?;
533 // Each person's latest verdict is the one that stands.
534 let mut latest: HashMap<String, Verdicts> = HashMap::new();
535 for row in rows {
536 latest.insert(row.author_id.clone(), row);
537 }
538 Ok(latest
539 .into_values()
540 .filter(|row| row.verdict == Verdict::RequestChanges)
541 .filter(|row| revised_at.is_none_or(|revised| row.created_at.as_str() > revised))
542 .max_by(|a, b| a.created_at.cmp(&b.created_at))
543 .map(|row| PersonRequest {
544 author_id: row.author_id,
545 author_name: row.author_name,
546 created_at: row.created_at,
547 }))
548 }
549
550 /// Marks a pull request a g1t agent has just opened as one g1t sees
551 /// through.
552 pub(crate) async fn manage(&self, pull: &Pull) -> Result<()> {
553 if !made_by_g1t(pull) {
554 return Ok(());
555 }
556 self.db
557 .prepare("UPDATE pulls SET managed = 1 WHERE id = ?")
558 .bind(&[pull.id.as_str().into()])?
559 .run()
560 .await?;
561 Ok(())
562 }
563
564 /// Takes a step for a pull request, if nobody else has. One statement,
565 /// so that two callers cannot both take it.
566 async fn claim(&self, pull_id: &str, step: &str, minutes: u64, revising: bool) -> Result<bool> {
567 let now = now_ms();
568 let revision = if revising {
569 ", revisions = revisions + 1, revised_at = ?1"
570 } else {
571 ""
572 };
573 Ok(self
574 .db
575 .prepare(format!(
576 "UPDATE pulls SET working_on = ?2, working_until = ?3{revision}
577 WHERE id = ?4 AND status = 'open' AND stalled IS NULL
578 AND (working_until IS NULL OR working_until < ?1)
579 RETURNING id AS value"
580 ))
581 .bind(&[
582 rfc3339(now).into(),
583 step.into(),
584 rfc3339(now + minutes * 60 * 1000).into(),
585 pull_id.into(),
586 ])?
587 .first::<ValueRow>(None)
588 .await?
589 .is_some())
590 }
591
592 /// What the author is told when sent back: the checks that failed and
593 /// what they printed, or the review and its comments on lines.
594 async fn feedback(&self, pull: &Pull, feedback: &Feedback) -> Result<String> {
595 match feedback {
596 Feedback::FailedChecks => {
Sidebar: the panels really slide597 let run = self.latest_checks(&pull.id).await?;
598 // Why it failed, when that is more than a list of commands:
599 // the merge queue saying what broke in the combined state.
600 let why = run.as_ref().and_then(|run| run.error.clone()).map(|error| format!("{error}\n\n")).unwrap_or_default();
601 let failed: Vec<String> = run
Agents as a team: lifecycle, merge queue, billing and a new shell602 .map(|run| run.results)
603 .unwrap_or_default()
604 .into_iter()
605 .filter(|result| !result.passed)
606 .map(|result| {
607 let length = result.output.chars().count();
608 let output: String = result
609 .output
610 .chars()
611 .skip(length.saturating_sub(MAX_CHECK_OUTPUT_CHARS))
612 .collect();
613 let exit = result
614 .exit_code
615 .map_or("it was stopped for taking too long".to_owned(), |code| {
616 format!("exit code {code}")
617 });
618 format!("`{}` failed ({exit}):\n\n{}", result.command, output.trim())
619 })
620 .collect();
Sidebar: the panels really slide621 if failed.is_empty() {
622 return Ok(format!(
623 "{why}Find the cause, fix it in your change, and push. Use get_workflow_run and get_job_logs for any workflow named above."
624 ));
625 }
Agents as a team: lifecycle, merge queue, billing and a new shell626 Ok(format!(
Sidebar: the panels really slide627 "{why}These acceptance checks were run against your change in a clean sandbox and failed.\n\n{}",
Agents as a team: lifecycle, merge queue, billing and a new shell628 failed.join("\n\n")
629 ))
630 }
GitHub Actions on g1t, part two: running workflows631 Feedback::FailedWorkflows => {
632 let statuses = self.statuses(&pull.repo_id, pull.head_commit.as_deref()).await?;
633 let failed: Vec<String> = statuses
634 .iter()
635 .filter(|s| s.state == "failure" || s.state == "error")
636 .map(|s| {
637 let run = s.target_url.as_deref().and_then(|url| url.rsplit('/').next()).unwrap_or_default();
638 format!(
Free while g1t is being built out; agents can check out their own forks639 "- {} ({}): run `{run}`, {}",
GitHub Actions on g1t, part two: running workflows640 s.context,
Free while g1t is being built out; agents can check out their own forks641 s.description.as_deref().unwrap_or("failed"),
642 s.target_url.as_deref().unwrap_or_default()
GitHub Actions on g1t, part two: running workflows643 )
644 })
645 .collect();
Free while g1t is being built out; agents can check out their own forks646 // Named outright: the agent cannot guess it from its fork.
647 let repo = g1t_kit::call::<_, Option<RepoPath>>(
648 &self.repos,
649 "path_by_id",
650 &g1t_contracts::repos::PathByIdArgs { id: pull.repo_id.clone() },
651 )
652 .await?
653 .map(|path| format!("{}/{}", path.namespace, path.name))
654 .unwrap_or_default();
GitHub Actions on g1t, part two: running workflows655 Ok(format!(
Free while g1t is being built out; agents can check out their own forks656 "These GitHub Actions workflows failed on your latest commit to {repo}:\n\n{}\n\n\
657 Read why with the `get_workflow_run` tool (repo `{repo}` and the run's id), \
GitHub Actions on g1t, part two: running workflows658 then `get_job_logs` for the job that failed. Fix the cause in the code, not the workflow, \
659 unless the workflow itself is wrong.",
660 failed.join("\n")
661 ))
662 }
Agents as a team: lifecycle, merge queue, billing and a new shell663 Feedback::Review(finished_at) => {
664 // Everything a review says is recorded at the moment it finished.
665 let notes = self
666 .db
667 .prepare(
668 "SELECT body, path, line FROM comments
669 WHERE repo_id = ? AND number = ? AND author_id = ? AND created_at = ?
670 ORDER BY id",
671 )
672 .bind(&[
673 pull.repo_id.as_str().into(),
674 pull.number.into(),
675 AGENT_ID.into(),
676 finished_at.as_str().into(),
677 ])?
678 .all()
679 .await?
680 .results::<ReviewNote>()?;
681 let mut on_lines = Vec::new();
682 let mut summary = String::new();
683 for note in notes {
684 match (note.path, note.line) {
685 (Some(path), Some(line)) => {
686 on_lines.push(format!("- `{path}` line {line}: {}", note.body));
687 }
688 (Some(path), None) => on_lines.push(format!("- `{path}`: {}", note.body)),
689 (None, _) => summary = note.body,
690 }
691 }
692 let mut text = format!(
693 "Another agent reviewed your change and asked for changes.\n\n{summary}"
694 );
695 if !on_lines.is_empty() {
696 text.push_str("\n\nIts comments on lines:\n");
697 text.push_str(&on_lines.join("\n"));
698 }
699 Ok(text)
700 }
701 Feedback::Person(request) => {
702 // What they wrote since the agent last revised, which their
703 // request for changes closes.
704 let revised: Option<String> = self
705 .db
706 .prepare("SELECT revised_at AS value FROM pulls WHERE id = ?")
707 .bind(&[pull.id.as_str().into()])?
708 .first::<Option<String>>(Some("value"))
709 .await?
710 .flatten();
711 let notes = self
712 .db
713 .prepare(
714 "SELECT body, path, line FROM comments
715 WHERE repo_id = ? AND number = ? AND author_id = ?
716 AND created_at > ? AND created_at <= ?
717 ORDER BY id",
718 )
719 .bind(&[
720 pull.repo_id.as_str().into(),
721 pull.number.into(),
722 request.author_id.as_str().into(),
723 revised.unwrap_or_default().into(),
724 request.created_at.as_str().into(),
725 ])?
726 .all()
727 .await?
728 .results::<ReviewNote>()?;
729 let mut on_lines = Vec::new();
730 let mut said = Vec::new();
731 for note in notes {
732 match (note.path, note.line) {
733 (Some(path), Some(line)) => {
734 on_lines.push(format!("- `{path}` line {line}: {}", note.body));
735 }
736 (Some(path), None) => on_lines.push(format!("- `{path}`: {}", note.body)),
737 (None, _) => said.push(note.body),
738 }
739 }
740 let mut text = format!(
741 "{} reviewed your change and asked for changes.\n\n{}",
742 request.author_name,
743 said.join("\n\n")
744 );
745 if !on_lines.is_empty() {
746 text.push_str("\n\nTheir comments on lines:\n");
747 text.push_str(&on_lines.join("\n"));
748 }
749 Ok(text)
750 }
751 }
752 }
753
754 pub(crate) async fn advance(&self, a: AdvanceArgs) -> Result<Advance> {
755 let Some(pull) = self.pull_by_id(&a.pull_id).await? else {
756 return Ok(Advance::None);
757 };
758 if pull.status != PullStatus::Open {
759 return Ok(Advance::None);
760 }
Agents move along on private repositories too761 // As a member: a private repository would look missing otherwise,
762 // and the pull request would never move.
763 let viewer: Viewer = self.author_viewer(&pull).await?;
Agents as a team: lifecycle, merge queue, billing and a new shell764 let repo: Outcome<Repo> = g1t_kit::call(
765 &self.repos,
766 "get_by_id",
767 &GetByIdArgs {
768 id: pull.repo_id.clone(),
769 viewer,
770 },
771 )
772 .await?;
773 let (Outcome::Ok(repo), Some(source)) = (repo, pull.fork.clone()) else {
774 return Ok(Advance::None);
775 };
776 let issue = match pull.issue {
777 Some(number) => self.issue(&pull.repo_id, number).await?,
778 None => None,
779 };
780 let behind = self.is_behind(&repo.id, &pull).await?;
781 let Some((lifecycle, next)) = self.assess(&pull, &issue, behind).await? else {
782 return Ok(Advance::None);
783 };
784
785 if matches!(next, Next::Merge) {
786 self.merge_by_policy(&repo, &pull).await?;
787 return Ok(Advance::None);
788 }
789 let (step, minutes) = match &next {
790 Next::Wait | Next::Merge => return Ok(Advance::None),
791 Next::Review => ("review", REVIEW_MINUTES),
792 Next::Revise(_) => ("revision", REVISION_MINUTES),
793 Next::CatchUp => ("catch_up", CATCH_UP_MINUTES),
794 };
795 let feedback = match &next {
796 Next::Revise(feedback) => self.feedback(&pull, feedback).await?,
797 _ => String::new(),
798 };
799 if !self
800 .claim(&pull.id, step, minutes, matches!(next, Next::Revise(_)))
801 .await?
802 {
803 return Ok(Advance::None);
804 }
805 // Said in the conversation, so nobody has to wonder why a review or
806 // a new commit appeared.
807 let told = match &next {
808 Next::Review => {
809 self.db
810 .prepare(
811 "UPDATE pulls SET reviewers = json_insert(reviewers, '$[#]', ?1)
812 WHERE id = ?2 AND NOT EXISTS (
813 SELECT 1 FROM json_each(pulls.reviewers) WHERE json_each.value = ?1)",
814 )
815 .bind(&[AGENT_NAME.into(), pull.id.as_str().into()])?
816 .run()
817 .await?;
818 "requested a review from g1t-agent".to_owned()
819 }
820 Next::Revise(Feedback::FailedChecks) => {
821 "sent g1t-agent back to fix the failed checks".to_owned()
822 }
GitHub Actions on g1t, part two: running workflows823 Next::Revise(Feedback::FailedWorkflows) => {
824 "sent g1t-agent back to fix the failed workflows".to_owned()
825 }
Agents as a team: lifecycle, merge queue, billing and a new shell826 Next::Revise(Feedback::Review(_)) => {
827 "sent g1t-agent back to address the review".to_owned()
828 }
829 _ => format!(
830 "asked g1t-agent to bring this up to date with {}",
831 repo.default_branch
832 ),
833 };
834 self.note(
835 &pull.repo_id,
836 pull.number,
837 (POLICY_ACTOR_ID, POLICY_ACTOR_NAME),
838 &told,
839 )
840 .await?;
841 let job = LifecycleJob {
842 pull_id: pull.id,
843 repo: RepoPath {
844 namespace: repo.namespace,
845 name: repo.name,
846 },
847 number: pull.number,
848 author: pull.author,
849 source,
850 branch: None,
851 default_branch: repo.default_branch,
852 title: pull.title,
853 description: pull.body.unwrap_or_default(),
854 issue,
855 feedback,
856 round: lifecycle.revisions + 1,
857 };
858 Ok(match next {
859 Next::Review => Advance::Review { job },
860 Next::Revise(_) => Advance::Revise { job },
861 Next::CatchUp => Advance::CatchUp { job },
862 Next::Wait | Next::Merge => Advance::None,
863 })
864 }
865
866 /// Lands a pull request that is ready, on the authority of the
867 /// repository's settings instead of a person's click.
868 async fn merge_by_policy(&self, repo: &Repo, pull: &Pull) -> Result<()> {
869 if !self.claim(&pull.id, "merge", MERGE_MINUTES, false).await? {
870 return Ok(());
871 }
872 // g1t acts for the workspace whose members turned this on.
873 let actor = User {
874 id: POLICY_ACTOR_ID.to_owned(),
875 username: POLICY_ACTOR_NAME.to_owned(),
876 verified: true,
877 workspaces: vec![Membership {
878 slug: repo.namespace.clone(),
879 role: Role::Member,
880 }],
881 ..User::default()
882 };
883 let merged = self
884 .merge_pull(PullActionArgs {
885 actor,
886 repo: RepoPath {
887 namespace: repo.namespace.clone(),
888 name: repo.name.clone(),
889 },
890 number: pull.number,
891 summary: String::new(),
892 keep_issue_open: false,
893 ignore_checks: false,
894 })
895 .await?;
896 match merged {
897 Outcome::Ok(_) => Ok(()),
898 // Most likely the branch moved in the moment between: let go, and
899 // the next look at it will catch up and try again.
900 Outcome::Fail(failure) if failure.code == FailureCode::Conflict => {
901 self.db
902 .prepare(
903 "UPDATE pulls SET working_on = NULL, working_until = NULL
904 WHERE id = ? AND working_on = 'merge'",
905 )
906 .bind(&[pull.id.as_str().into()])?
907 .run()
908 .await?;
909 Ok(())
910 }
911 Outcome::Fail(failure) => {
912 self.stall(StallArgs {
913 pull_id: pull.id.clone(),
914 reason: format!("g1t could not merge this: {}", failure.message),
915 })
916 .await?;
917 Ok(())
918 }
919 }
920 }
921
922 /// Records that a merge was asked for while the pull request was
923 /// behind, and announces it so that the runner brings it up to date.
924 pub(crate) async fn request_landing(
925 &self,
926 pull: &Pull,
927 actor: &User,
928 keep_issue_open: bool,
929 ) -> Result<()> {
930 let now = now_ms();
931 let request = serde_json::to_string(&LandRequest {
932 actor: actor.clone(),
933 keep_issue_open,
934 })?;
935 self.db
936 .prepare(
937 "UPDATE pulls
938 SET land_requested = ?, land_requested_at = ?, stalled = NULL,
939 working_on = 'catch_up', working_until = ?
940 WHERE id = ?",
941 )
942 .bind(&[
943 request.into(),
944 rfc3339(now).into(),
945 rfc3339(now + CATCH_UP_MINUTES * 60 * 1000).into(),
946 pull.id.as_str().into(),
947 ])?
948 .run()
949 .await?;
950 self.publish(
951 "pull.merge_requested",
952 &pull.repo_id,
953 actor,
954 PullEvent {
955 pull_id: pull.id.clone(),
956 repo_id: pull.repo_id.clone(),
957 number: pull.number,
958 issue: pull.issue,
959 ..PullEvent::default()
960 },
961 )
962 .await
963 }
964
965 /// The merge waiting on a pull request, if one was asked for recently
966 /// enough to still stand.
967 async fn land_request(&self, pull_id: &str) -> Result<Option<LandRequest>> {
968 let row = self
969 .db
970 .prepare("SELECT land_requested, land_requested_at, stalled FROM pulls WHERE id = ?")
971 .bind(&[pull_id.into()])?
972 .first::<LandRow>(None)
973 .await?;
974 let oldest = rfc3339(now_ms().saturating_sub(CATCH_UP_MINUTES * 60 * 1000));
975 Ok(row
976 .filter(|row| {
977 row.land_requested_at
978 .as_deref()
979 .is_some_and(|at| at >= oldest.as_str())
980 })
981 .and_then(|row| row.land_requested)
982 .and_then(|request| serde_json::from_str(&request).ok()))
983 }
984
985 /// Whether a merge is waiting on a pull request, and why g1t stopped
986 /// working on it if it did.
987 pub(crate) async fn landing_state(&self, pull_id: &str) -> Result<(bool, Option<String>)> {
988 let stalled = self
989 .db
990 .prepare("SELECT land_requested, land_requested_at, stalled FROM pulls WHERE id = ?")
991 .bind(&[pull_id.into()])?
992 .first::<LandRow>(None)
993 .await?
994 .and_then(|row| row.stalled);
995 Ok((self.land_request(pull_id).await?.is_some(), stalled))
996 }
997
998 async fn forget_landing(&self, pull_id: &str) -> Result<()> {
999 self.db
1000 .prepare(
1001 "UPDATE pulls SET land_requested = NULL, land_requested_at = NULL WHERE id = ?",
1002 )
1003 .bind(&[pull_id.into()])?
1004 .run()
1005 .await?;
1006 Ok(())
1007 }
1008
1009 /// Lands a pull request whose head has just moved, if a merge of it was
1010 /// waiting for exactly that. What it was caught up to was already
1011 /// checked and reviewed apart from the merge, so the checks are not
1012 /// waited for again; a repository that wants them rerun turns on
1013 /// "require up to date", and then nothing is landed this way.
1014 pub(crate) async fn land_if_requested(&self, pull_id: &str) -> Result<()> {
1015 let Some(request) = self.land_request(pull_id).await? else {
1016 return Ok(());
1017 };
1018 self.forget_landing(pull_id).await?;
1019 let Some(pull) = self.pull_by_id(pull_id).await? else {
1020 return Ok(());
1021 };
1022 let repo: Outcome<Repo> = g1t_kit::call(
1023 &self.repos,
1024 "get_by_id",
1025 &GetByIdArgs {
1026 id: pull.repo_id.clone(),
1027 viewer: Some(request.actor.clone()),
1028 },
1029 )
1030 .await?;
1031 let Outcome::Ok(repo) = repo else {
1032 return Ok(());
1033 };
1034 let merged = self
1035 .merge_pull(PullActionArgs {
1036 actor: request.actor,
1037 repo: RepoPath {
1038 namespace: repo.namespace,
1039 name: repo.name,
1040 },
1041 number: pull.number,
1042 summary: String::new(),
1043 keep_issue_open: request.keep_issue_open,
1044 ignore_checks: true,
1045 })
1046 .await?;
1047 if let Outcome::Fail(failure) = merged {
1048 self.stall(StallArgs {
1049 pull_id: pull.id,
1050 reason: format!(
1051 "It was brought up to date but could not be merged: {}",
1052 failure.message
1053 ),
1054 })
1055 .await?;
1056 }
1057 Ok(())
1058 }
1059
1060 /// What the runner needs to bring a pull request up to date for a merge
1061 /// that is waiting on it.
1062 pub(crate) async fn catch_up_job(&self, a: CatchUpJobArgs) -> Result<Option<LifecycleJob>> {
1063 if self.land_request(&a.pull_id).await?.is_none() {
1064 return Ok(None);
1065 }
1066 let Some(pull) = self.pull_by_id(&a.pull_id).await? else {
1067 return Ok(None);
1068 };
1069 // Its author can read both the repository and the pull request's source.
1070 let repo: Outcome<Repo> = g1t_kit::call(
1071 &self.repos,
1072 "get_by_id",
1073 &GetByIdArgs {
1074 id: pull.repo_id.clone(),
Agents move along on private repositories too1075 viewer: self.author_viewer(&pull).await?,
Agents as a team: lifecycle, merge queue, billing and a new shell1076 },
1077 )
1078 .await?;
1079 let Outcome::Ok(repo) = repo else {
1080 return Ok(None);
1081 };
1082 let path = RepoPath {
1083 namespace: repo.namespace,
1084 name: repo.name,
1085 };
1086 let issue = match pull.issue {
1087 Some(number) => self.issue(&pull.repo_id, number).await?,
1088 None => None,
1089 };
1090 Ok(Some(LifecycleJob {
1091 pull_id: pull.id,
1092 source: pull.fork.unwrap_or_else(|| path.clone()),
1093 repo: path,
1094 number: pull.number,
1095 author: pull.author,
1096 branch: pull.branch,
1097 default_branch: repo.default_branch,
1098 title: pull.title,
1099 description: pull.body.unwrap_or_default(),
1100 issue,
1101 feedback: String::new(),
1102 round: 0,
1103 }))
1104 }
1105
1106 pub(crate) async fn stall(&self, a: StallArgs) -> Result<bool> {
1107 self.db
1108 .prepare(
1109 "UPDATE pulls
1110 SET stalled = ?, working_on = NULL, working_until = NULL,
1111 land_requested = NULL, land_requested_at = NULL
1112 WHERE id = ? AND status = 'open'",
1113 )
1114 .bind(&[a.reason.trim().into(), a.pull_id.as_str().into()])?
1115 .run()
1116 .await?;
1117 self.db
1118 .prepare("UPDATE pulls SET stage = 'needs_you', stage_detail = ? WHERE id = ?")
1119 .bind(&[a.reason.trim().into(), a.pull_id.into()])?
1120 .run()
1121 .await?;
1122 Ok(true)
1123 }
1124
1125 pub(crate) async fn managed_pulls(&self, a: ManagedPullsArgs) -> Result<Vec<String>> {
1126 let rows = self
1127 .db
1128 .prepare(
1129 "SELECT id AS value FROM pulls
1130 WHERE status = 'open' AND managed = 1 AND stalled IS NULL
1131 AND (?1 IS NULL OR repo_id = ?1)
1132 ORDER BY updated_at DESC LIMIT ?2",
1133 )
1134 .bind(&[
1135 a.repo_id.map_or(JsValue::NULL, JsValue::from),
1136 MANAGED_PAGE.into(),
1137 ])?
1138 .all()
1139 .await?
1140 .results::<ValueRow>()?;
1141 Ok(rows.into_iter().map(|row| row.value).collect())
1142 }
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147 use super::*;
1148
1149 const MAX_REVISIONS: u32 = 2;
1150
1151 #[test]
1152 fn approvals_the_repository_wants_are_waited_for() {
1153 let short = || Facts {
1154 review: reviewed(Some(Verdict::Approve)),
1155 approvals_missing: Some("This repository requires 1 approving review.".to_owned()),
1156 ..facts()
1157 };
1158 let (lifecycle, next) = decide(short());
1159 assert_eq!(lifecycle.stage, Stage::NeedsYou);
1160 assert_eq!(
1161 lifecycle.detail,
1162 "This repository requires 1 approving review."
1163 );
1164 assert!(matches!(next, Next::Wait));
1165 // Not even a repository that merges by itself merges without them.
1166 let automatic = Facts {
1167 auto_merge: true,
1168 ..short()
1169 };
1170 assert_eq!(outcome(automatic), (Stage::NeedsYou, "wait"));
1171 }
1172
1173 #[test]
1174 fn a_repository_can_leave_review_to_people() {
1175 let unreviewed = Facts {
1176 agent_review: false,
1177 ..facts()
1178 };
1179 assert_eq!(outcome(unreviewed), (Stage::Ready, "wait"));
1180 let failing = Facts {
1181 agent_review: false,
1182 check_status: Some(CheckStatus::Failed),
1183 ..facts()
1184 };
1185 assert_eq!(outcome(failing), (Stage::Revising, "revise for checks"));
1186 }
1187
1188 #[test]
1189 fn a_repository_sets_how_often_the_author_is_sent_back() {
1190 let never = Facts {
1191 max_revisions: 0,
1192 check_status: Some(CheckStatus::Failed),
1193 ..facts()
1194 };
1195 assert_eq!(outcome(never), (Stage::NeedsYou, "wait"));
1196 }
1197
1198 /// A pull request that is ready for review, with checks that passed
1199 /// and nothing else yet.
1200 fn facts() -> Facts {
1201 Facts {
1202 draft: false,
1203 stalled: None,
1204 working_on: None,
1205 check_status: Some(CheckStatus::Passed),
1206 review_pending: false,
1207 has_checks: true,
1208 revisions: 0,
1209 review: None,
1210 behind: false,
1211 auto_merge: false,
1212 require_up_to_date: false,
1213 agent_review: true,
1214 max_revisions: MAX_REVISIONS,
1215 approvals_missing: None,
1216 person_request: None,
1217 queued: None,
GitHub Actions on g1t, part two: running workflows1218 workflows: WorkflowFacts::default(),
Agents as a team: lifecycle, merge queue, billing and a new shell1219 }
1220 }
1221
1222 #[test]
GitHub Actions on g1t, part two: running workflows1223 fn failed_workflows_send_the_agent_back_and_running_ones_wait() {
1224 let failed = Facts {
1225 workflows: WorkflowFacts { pending: vec![], failed: vec!["CI / pull_request".into()] },
1226 ..facts()
1227 };
1228 let (lifecycle, next) = decide(failed);
1229 assert!(matches!(next, Next::Revise(Feedback::FailedWorkflows)));
1230 assert!(lifecycle.detail.contains("CI / pull_request failed"));
1231 let running = Facts {
1232 workflows: WorkflowFacts { pending: vec!["CI / pull_request".into()], failed: vec![] },
1233 ..facts()
1234 };
1235 let (lifecycle, next) = decide(running);
1236 assert!(matches!(next, Next::Wait));
1237 assert!(lifecycle.detail.contains("Waiting for CI / pull_request"));
1238 }
1239
1240 #[test]
Agents as a team: lifecycle, merge queue, billing and a new shell1241 fn a_queued_pull_request_waits_in_the_queue() {
1242 let queued = Facts {
1243 review: reviewed(Some(Verdict::Approve)),
1244 queued: Some((QueueState::Testing, vec![12, 14])),
1245 auto_merge: true,
1246 ..facts()
1247 };
1248 let (lifecycle, next) = decide(queued);
1249 assert_eq!(lifecycle.stage, Stage::Queued);
1250 assert!(lifecycle.detail.contains("#12, #14"));
1251 assert!(matches!(next, Next::Wait));
1252 }
1253
1254 fn asked_by_a_person() -> Option<PersonRequest> {
1255 Some(PersonRequest {
1256 author_id: "usr_reviewer".to_owned(),
1257 author_name: "g1t-reviewer".to_owned(),
1258 created_at: "2026-10-02T11:00:00.000Z".to_owned(),
1259 })
1260 }
1261
1262 #[test]
1263 fn a_person_asking_for_changes_sends_the_agent_back() {
1264 let asked = Facts {
1265 review: reviewed(Some(Verdict::Approve)),
1266 approvals_missing: Some("A reviewer has asked for changes.".to_owned()),
1267 person_request: asked_by_a_person(),
1268 ..facts()
1269 };
1270 let (lifecycle, _) = decide(Facts {
1271 person_request: asked_by_a_person(),
1272 ..facts()
1273 });
1274 assert!(lifecycle.detail.starts_with("g1t-reviewer asked for changes"));
1275 assert_eq!(outcome(asked), (Stage::Revising, "revise for a person"));
1276 }
1277
1278 #[test]
1279 fn a_person_is_asked_once_the_revisions_run_out() {
1280 let exhausted = Facts {
1281 person_request: asked_by_a_person(),
1282 revisions: MAX_REVISIONS,
1283 ..facts()
1284 };
1285 assert_eq!(outcome(exhausted), (Stage::NeedsYou, "wait"));
1286 }
1287
1288 fn reviewed(verdict: Option<Verdict>) -> Option<FinishedReview> {
1289 Some(FinishedReview {
1290 finished_at: "2026-10-02T10:00:00.000Z".to_owned(),
1291 verdict,
1292 })
1293 }
1294
1295 /// The stage, and a word for the step to take.
1296 fn outcome(facts: Facts) -> (Stage, &'static str) {
1297 let (lifecycle, next) = decide(facts);
1298 let step = match next {
1299 Next::Wait => "wait",
1300 Next::Review => "review",
1301 Next::Revise(Feedback::FailedChecks) => "revise for checks",
GitHub Actions on g1t, part two: running workflows1302 Next::Revise(Feedback::FailedWorkflows) => "revise for workflows",
Agents as a team: lifecycle, merge queue, billing and a new shell1303 Next::Revise(Feedback::Review(_)) => "revise for review",
1304 Next::Revise(Feedback::Person(_)) => "revise for a person",
1305 Next::CatchUp => "catch up",
1306 Next::Merge => "merge",
1307 };
1308 (lifecycle.stage, step)
1309 }
1310
1311 #[test]
1312 fn nothing_is_started_while_the_agent_is_still_working() {
1313 let draft = Facts {
1314 draft: true,
1315 check_status: None,
1316 ..facts()
1317 };
1318 assert_eq!(outcome(draft), (Stage::Working, "wait"));
1319 }
1320
1321 #[test]
1322 fn checks_come_before_review() {
1323 let unchecked = Facts {
1324 check_status: None,
1325 ..facts()
1326 };
1327 assert_eq!(outcome(unchecked), (Stage::Checking, "wait"));
1328 let running = Facts {
1329 check_status: Some(CheckStatus::Running),
1330 ..facts()
1331 };
1332 assert_eq!(outcome(running), (Stage::Checking, "wait"));
1333 assert_eq!(outcome(facts()), (Stage::Reviewing, "review"));
1334 }
1335
1336 #[test]
1337 fn an_issue_without_checks_goes_straight_to_review() {
1338 let unchecked = Facts {
1339 has_checks: false,
1340 check_status: None,
1341 ..facts()
1342 };
1343 assert_eq!(outcome(unchecked), (Stage::Reviewing, "review"));
1344 }
1345
1346 #[test]
1347 fn failed_checks_send_the_author_back() {
1348 let failed = Facts {
1349 check_status: Some(CheckStatus::Failed),
1350 ..facts()
1351 };
1352 assert_eq!(outcome(failed), (Stage::Revising, "revise for checks"));
1353 }
1354
1355 #[test]
1356 fn checks_that_could_not_run_are_a_persons_problem() {
1357 let errored = Facts {
1358 check_status: Some(CheckStatus::Errored),
1359 ..facts()
1360 };
1361 assert_eq!(outcome(errored), (Stage::NeedsYou, "wait"));
1362 }
1363
1364 #[test]
1365 fn a_review_asking_for_changes_sends_the_author_back() {
1366 let changes = Facts {
1367 review: reviewed(Some(Verdict::RequestChanges)),
1368 ..facts()
1369 };
1370 assert_eq!(outcome(changes), (Stage::Revising, "revise for review"));
1371 }
1372
1373 #[test]
1374 fn the_author_is_sent_back_only_so_many_times() {
1375 let failing = Facts {
1376 check_status: Some(CheckStatus::Failed),
1377 revisions: MAX_REVISIONS,
1378 ..facts()
1379 };
1380 assert_eq!(outcome(failing), (Stage::NeedsYou, "wait"));
1381 let unconvinced = Facts {
1382 review: reviewed(Some(Verdict::RequestChanges)),
1383 revisions: MAX_REVISIONS,
1384 ..facts()
1385 };
1386 assert_eq!(outcome(unconvinced), (Stage::NeedsYou, "wait"));
1387 // One short of the limit still gets another go.
1388 let once = Facts {
1389 check_status: Some(CheckStatus::Failed),
1390 revisions: MAX_REVISIONS - 1,
1391 ..facts()
1392 };
1393 assert_eq!(outcome(once), (Stage::Revising, "revise for checks"));
1394 }
1395
1396 #[test]
1397 fn a_review_that_could_not_be_written_is_not_retried() {
1398 let broken = Facts {
1399 review: reviewed(None),
1400 ..facts()
1401 };
1402 assert_eq!(outcome(broken), (Stage::NeedsYou, "wait"));
1403 }
1404
1405 #[test]
1406 fn being_behind_only_holds_a_change_up_where_the_repository_says_so() {
1407 let behind = || Facts {
1408 review: reviewed(Some(Verdict::Approve)),
1409 behind: true,
1410 ..facts()
1411 };
1412 // By default it is ready as it is; merging brings it up to date.
1413 assert_eq!(outcome(behind()), (Stage::Ready, "wait"));
1414 let strict = Facts {
1415 require_up_to_date: true,
1416 ..behind()
1417 };
1418 assert_eq!(outcome(strict), (Stage::CatchingUp, "catch up"));
1419 let current = Facts {
1420 review: reviewed(Some(Verdict::Approve)),
1421 ..facts()
1422 };
1423 assert_eq!(outcome(current), (Stage::Ready, "wait"));
1424 }
1425
1426 #[test]
1427 fn a_ready_change_lands_by_itself_only_where_the_repository_says_so() {
1428 let ready = || Facts {
1429 review: reviewed(Some(Verdict::Approve)),
1430 ..facts()
1431 };
1432 assert_eq!(outcome(ready()), (Stage::Ready, "wait"));
1433 let automatic = Facts {
1434 auto_merge: true,
1435 ..ready()
1436 };
1437 assert_eq!(outcome(automatic), (Stage::Ready, "merge"));
1438 // One that is behind is merged too: merging brings it up to date.
1439 let behind = Facts {
1440 auto_merge: true,
1441 behind: true,
1442 ..ready()
1443 };
1444 assert_eq!(outcome(behind), (Stage::Ready, "merge"));
1445 // Unless the repository wants it caught up and checked again first.
1446 let strict = Facts {
1447 auto_merge: true,
1448 behind: true,
1449 require_up_to_date: true,
1450 ..ready()
1451 };
1452 assert_eq!(outcome(strict), (Stage::CatchingUp, "catch up"));
1453 // Nothing short of approved is merged, whatever the setting.
1454 let failing = Facts {
1455 auto_merge: true,
1456 check_status: Some(CheckStatus::Failed),
1457 ..ready()
1458 };
1459 assert_eq!(outcome(failing), (Stage::Revising, "revise for checks"));
1460 let unreviewed = Facts {
1461 auto_merge: true,
1462 ..facts()
1463 };
1464 assert_eq!(outcome(unreviewed), (Stage::Reviewing, "review"));
1465 }
1466
1467 #[test]
1468 fn catching_up_reruns_the_checks_but_not_the_review() {
1469 // The merge moved the head, so the checks are waited for again.
1470 let merged_in = Facts {
1471 review: reviewed(Some(Verdict::Approve)),
1472 check_status: None,
1473 ..facts()
1474 };
1475 assert_eq!(outcome(merged_in), (Stage::Checking, "wait"));
1476 }
1477
1478 #[test]
1479 fn a_step_under_way_is_not_started_again() {
1480 for (step, stage) in [
1481 ("review", Stage::Reviewing),
1482 ("revision", Stage::Revising),
1483 ("catch_up", Stage::CatchingUp),
1484 ] {
1485 let busy = Facts {
1486 working_on: Some(step.to_owned()),
1487 // Whatever else is true, the step in hand comes first.
1488 check_status: Some(CheckStatus::Failed),
1489 ..facts()
1490 };
1491 assert_eq!(outcome(busy), (stage, "wait"));
1492 }
1493 let asked = Facts {
1494 review_pending: true,
1495 ..facts()
1496 };
1497 assert_eq!(outcome(asked), (Stage::Reviewing, "wait"));
1498 }
1499
1500 #[test]
1501 fn once_stopped_it_stays_stopped() {
1502 let (lifecycle, next) = decide(Facts {
1503 stalled: Some("The agent could not catch up.".to_owned()),
1504 review: reviewed(Some(Verdict::Approve)),
1505 behind: true,
1506 ..facts()
1507 });
1508 assert_eq!(lifecycle.stage, Stage::NeedsYou);
1509 assert_eq!(lifecycle.detail, "The agent could not catch up.");
1510 assert!(matches!(next, Next::Wait));
1511 }
1512}