g1t/services/work/src/lifecycle.rs

1,503 lines54,385 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.
36const POLICY_ACTOR_ID: &str = "g1t_policy";
37const POLICY_ACTOR_NAME: &str = "g1t";
38/// 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
254 if facts.has_checks {
255 match facts.check_status {
256 Some(CheckStatus::Passed) => {}
257 Some(CheckStatus::Failed) if exhausted => {
258 return wait(
259 Stage::NeedsYou,
260 &format!(
261 "The acceptance checks still fail after the agent revised {}.",
262 times(revisions)
263 ),
264 );
265 }
266 Some(CheckStatus::Failed) => {
267 return (
268 at(
269 Stage::Revising,
270 "The acceptance checks failed. The agent is being sent back to fix them.",
271 revisions,
272 ),
273 Next::Revise(Feedback::FailedChecks),
274 );
275 }
276 Some(CheckStatus::Errored) => {
277 return wait(Stage::NeedsYou, "The acceptance checks could not be run.");
278 }
279 _ => {
280 return wait(
281 Stage::Checking,
282 "Waiting for the acceptance checks to start.",
283 );
284 }
285 }
286 }
287
GitHub Actions on g1t, part two: running workflows288 // Its workflows, like its checks, must pass.
289 if !facts.workflows.failed.is_empty() {
290 let failed = statuses::list(&facts.workflows.failed);
291 if exhausted {
292 return wait(
293 Stage::NeedsYou,
294 &format!("{failed} still fails after the agent revised {}.", times(revisions)),
295 );
296 }
297 return (
298 at(
299 Stage::Revising,
300 format!("{failed} failed. The agent is being sent back to fix it."),
301 revisions,
302 ),
303 Next::Revise(Feedback::FailedWorkflows),
304 );
305 }
306 if !facts.workflows.pending.is_empty() {
307 return wait(
308 Stage::Checking,
309 &format!("Waiting for {} to finish.", statuses::list(&facts.workflows.pending)),
310 );
311 }
312
Agents as a team: lifecycle, merge queue, billing and a new shell313 // A second agent reviews it, unless the repository leaves review to people.
314 if facts.agent_review {
315 match facts.review {
316 None => {
317 return (
318 at(
319 Stage::Reviewing,
320 "A g1t agent is about to review the change.",
321 revisions,
322 ),
323 Next::Review,
324 );
325 }
326 Some(FinishedReview { verdict: None, .. }) => {
327 return wait(Stage::NeedsYou, "The review could not be completed.");
328 }
329 Some(FinishedReview {
330 verdict: Some(Verdict::RequestChanges),
331 ..
332 }) if exhausted => {
333 return wait(
334 Stage::NeedsYou,
335 &format!(
336 "The review still asks for changes after the agent revised {}.",
337 times(revisions)
338 ),
339 );
340 }
341 Some(FinishedReview {
342 verdict: Some(Verdict::RequestChanges),
343 finished_at,
344 }) => {
345 return (
346 at(
347 Stage::Revising,
348 "The review asked for changes. The agent is being sent back to make them.",
349 revisions,
350 ),
351 Next::Revise(Feedback::Review(finished_at)),
352 );
353 }
354 Some(FinishedReview {
355 verdict: Some(Verdict::Approve),
356 ..
357 }) => {}
358 }
359 }
360
361 // Only where the repository insists is catching up a step of its own,
362 // followed by the checks again. Elsewhere it happens as part of merging.
363 if facts.behind && facts.require_up_to_date {
364 return (
365 at(
366 Stage::CatchingUp,
367 "The branch it will land on has moved. The agent is catching up.",
368 revisions,
369 ),
370 Next::CatchUp,
371 );
372 }
373 // The repository wants approvals this does not have yet: people's turn,
374 // so it is shown as needing someone, not as g1t still working.
375 if let Some(missing) = &facts.approvals_missing {
376 return wait(Stage::NeedsYou, missing);
377 }
378 if facts.auto_merge {
379 return (
380 at(
381 Stage::Ready,
382 "Everything this repository asks for is met. Merging, as its settings say.",
383 revisions,
384 ),
385 Next::Merge,
386 );
387 }
388 wait(
389 Stage::Ready,
390 if facts.behind {
391 "Ready to merge. Merging brings it up to date with the default branch first."
392 } else {
393 "Everything this repository asks for is met. Ready to merge."
394 },
395 )
396}
397
398impl Work {
399 /// Where a pull request stands and what g1t does next, remembered so
400 /// lists can show it without working it out again. `None` for one g1t
401 /// is not seeing through.
402 pub(crate) async fn assess(
403 &self,
404 pull: &Pull,
405 issue: &Option<Issue>,
406 behind: bool,
407 ) -> Result<Option<(Lifecycle, Next)>> {
408 let assessed = self.assess_now(pull, issue, behind).await?;
409 if let Some((lifecycle, _)) = &assessed {
410 self.remember(&pull.id, lifecycle).await?;
411 }
412 Ok(assessed)
413 }
414
415 /// Saves where a pull request stands, for [`Self::remembered`].
416 pub(crate) async fn remember(&self, pull_id: &str, lifecycle: &Lifecycle) -> Result<()> {
417 let stage = serde_json::to_value(lifecycle.stage)?;
418 self.db
419 .prepare("UPDATE pulls SET stage = ?, stage_detail = ? WHERE id = ?")
420 .bind(&[
421 stage.as_str().unwrap_or_default().into(),
422 lifecycle.detail.as_str().into(),
423 pull_id.into(),
424 ])?
425 .run()
426 .await?;
427 Ok(())
428 }
429
430 async fn assess_now(
431 &self,
432 pull: &Pull,
433 issue: &Option<Issue>,
434 behind: bool,
435 ) -> Result<Option<(Lifecycle, Next)>> {
436 if !pull.status.is_active() {
437 return Ok(None);
438 }
439 let Some(progress) = self
440 .db
441 .prepare(
442 "SELECT managed, revisions, revised_at, working_on, working_until, stalled
443 FROM pulls WHERE id = ?",
444 )
445 .bind(&[pull.id.as_str().into()])?
446 .first::<Progress>(None)
447 .await?
448 .filter(|progress| progress.managed != 0)
449 else {
450 return Ok(None);
451 };
452 let now = rfc3339(now_ms());
453 let working_on = progress
454 .working_until
455 .as_deref()
456 .is_some_and(|until| until > now.as_str())
457 .then(|| progress.working_on.clone().unwrap_or_default());
458 let review = self
459 .db
460 .prepare(
461 "SELECT finished_at, verdict FROM review_runs
462 WHERE pull_id = ? AND finished_at IS NOT NULL ORDER BY id DESC LIMIT 1",
463 )
464 .bind(&[pull.id.as_str().into()])?
465 .first::<FinishedReview>(None)
466 .await?
467 // A review of what the change was before its last revision says
468 // nothing about what it is now.
469 .filter(|review| {
470 progress
471 .revised_at
472 .as_deref()
473 .is_none_or(|revised| review.finished_at.as_str() >= revised)
474 });
475 let settings = self.settings(&pull.repo_id).await?;
476 Ok(Some(decide(Facts {
477 draft: pull.status == PullStatus::Draft,
478 stalled: progress.stalled,
479 working_on,
480 check_status: pull.check_status,
481 review_pending: self.review_pending(&pull.id).await?,
482 has_checks: issue.as_ref().is_some_and(|issue| !issue.checks.is_empty()),
483 revisions: progress.revisions,
484 review,
485 behind,
486 auto_merge: settings.auto_merge,
487 require_up_to_date: settings.require_up_to_date,
488 agent_review: settings.agent_review,
489 max_revisions: settings.max_revisions,
490 approvals_missing: self.approvals_gap(&settings, pull).await?,
491 person_request: self
492 .person_request(pull, progress.revised_at.as_deref())
493 .await?,
494 queued: self.queued_entry(&pull.id).await?,
GitHub Actions on g1t, part two: running workflows495 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 shell496 })))
497 }
498
499 /// The latest request for changes by a person other than the author,
500 /// if it is that person's latest verdict and came after the last
501 /// revision.
502 async fn person_request(
503 &self,
504 pull: &Pull,
505 revised_at: Option<&str>,
506 ) -> Result<Option<PersonRequest>> {
507 #[derive(Deserialize)]
508 struct Verdicts {
509 author_id: String,
510 author_name: String,
511 verdict: Verdict,
512 created_at: String,
513 }
514 let rows = self
515 .db
516 .prepare(
517 "SELECT author_id, author_name, verdict, created_at FROM comments
518 WHERE repo_id = ? AND number = ? AND verdict IS NOT NULL
519 AND author_id != ? AND author_id != ?
520 ORDER BY id",
521 )
522 .bind(&[
523 pull.repo_id.as_str().into(),
524 pull.number.into(),
525 pull.author.id.as_str().into(),
526 AGENT_ID.into(),
527 ])?
528 .all()
529 .await?
530 .results::<Verdicts>()?;
531 // Each person's latest verdict is the one that stands.
532 let mut latest: HashMap<String, Verdicts> = HashMap::new();
533 for row in rows {
534 latest.insert(row.author_id.clone(), row);
535 }
536 Ok(latest
537 .into_values()
538 .filter(|row| row.verdict == Verdict::RequestChanges)
539 .filter(|row| revised_at.is_none_or(|revised| row.created_at.as_str() > revised))
540 .max_by(|a, b| a.created_at.cmp(&b.created_at))
541 .map(|row| PersonRequest {
542 author_id: row.author_id,
543 author_name: row.author_name,
544 created_at: row.created_at,
545 }))
546 }
547
548 /// Marks a pull request a g1t agent has just opened as one g1t sees
549 /// through.
550 pub(crate) async fn manage(&self, pull: &Pull) -> Result<()> {
551 if !made_by_g1t(pull) {
552 return Ok(());
553 }
554 self.db
555 .prepare("UPDATE pulls SET managed = 1 WHERE id = ?")
556 .bind(&[pull.id.as_str().into()])?
557 .run()
558 .await?;
559 Ok(())
560 }
561
562 /// Takes a step for a pull request, if nobody else has. One statement,
563 /// so that two callers cannot both take it.
564 async fn claim(&self, pull_id: &str, step: &str, minutes: u64, revising: bool) -> Result<bool> {
565 let now = now_ms();
566 let revision = if revising {
567 ", revisions = revisions + 1, revised_at = ?1"
568 } else {
569 ""
570 };
571 Ok(self
572 .db
573 .prepare(format!(
574 "UPDATE pulls SET working_on = ?2, working_until = ?3{revision}
575 WHERE id = ?4 AND status = 'open' AND stalled IS NULL
576 AND (working_until IS NULL OR working_until < ?1)
577 RETURNING id AS value"
578 ))
579 .bind(&[
580 rfc3339(now).into(),
581 step.into(),
582 rfc3339(now + minutes * 60 * 1000).into(),
583 pull_id.into(),
584 ])?
585 .first::<ValueRow>(None)
586 .await?
587 .is_some())
588 }
589
590 /// What the author is told when sent back: the checks that failed and
591 /// what they printed, or the review and its comments on lines.
592 async fn feedback(&self, pull: &Pull, feedback: &Feedback) -> Result<String> {
593 match feedback {
594 Feedback::FailedChecks => {
595 let failed: Vec<String> = self
596 .latest_checks(&pull.id)
597 .await?
598 .map(|run| run.results)
599 .unwrap_or_default()
600 .into_iter()
601 .filter(|result| !result.passed)
602 .map(|result| {
603 let length = result.output.chars().count();
604 let output: String = result
605 .output
606 .chars()
607 .skip(length.saturating_sub(MAX_CHECK_OUTPUT_CHARS))
608 .collect();
609 let exit = result
610 .exit_code
611 .map_or("it was stopped for taking too long".to_owned(), |code| {
612 format!("exit code {code}")
613 });
614 format!("`{}` failed ({exit}):\n\n{}", result.command, output.trim())
615 })
616 .collect();
617 Ok(format!(
618 "These acceptance checks were run against your change in a clean sandbox and failed.\n\n{}",
619 failed.join("\n\n")
620 ))
621 }
GitHub Actions on g1t, part two: running workflows622 Feedback::FailedWorkflows => {
623 let statuses = self.statuses(&pull.repo_id, pull.head_commit.as_deref()).await?;
624 let failed: Vec<String> = statuses
625 .iter()
626 .filter(|s| s.state == "failure" || s.state == "error")
627 .map(|s| {
628 let run = s.target_url.as_deref().and_then(|url| url.rsplit('/').next()).unwrap_or_default();
629 format!(
Free while g1t is being built out; agents can check out their own forks630 "- {} ({}): run `{run}`, {}",
GitHub Actions on g1t, part two: running workflows631 s.context,
Free while g1t is being built out; agents can check out their own forks632 s.description.as_deref().unwrap_or("failed"),
633 s.target_url.as_deref().unwrap_or_default()
GitHub Actions on g1t, part two: running workflows634 )
635 })
636 .collect();
Free while g1t is being built out; agents can check out their own forks637 // Named outright: the agent cannot guess it from its fork.
638 let repo = g1t_kit::call::<_, Option<RepoPath>>(
639 &self.repos,
640 "path_by_id",
641 &g1t_contracts::repos::PathByIdArgs { id: pull.repo_id.clone() },
642 )
643 .await?
644 .map(|path| format!("{}/{}", path.namespace, path.name))
645 .unwrap_or_default();
GitHub Actions on g1t, part two: running workflows646 Ok(format!(
Free while g1t is being built out; agents can check out their own forks647 "These GitHub Actions workflows failed on your latest commit to {repo}:\n\n{}\n\n\
648 Read why with the `get_workflow_run` tool (repo `{repo}` and the run's id), \
GitHub Actions on g1t, part two: running workflows649 then `get_job_logs` for the job that failed. Fix the cause in the code, not the workflow, \
650 unless the workflow itself is wrong.",
651 failed.join("\n")
652 ))
653 }
Agents as a team: lifecycle, merge queue, billing and a new shell654 Feedback::Review(finished_at) => {
655 // Everything a review says is recorded at the moment it finished.
656 let notes = self
657 .db
658 .prepare(
659 "SELECT body, path, line FROM comments
660 WHERE repo_id = ? AND number = ? AND author_id = ? AND created_at = ?
661 ORDER BY id",
662 )
663 .bind(&[
664 pull.repo_id.as_str().into(),
665 pull.number.into(),
666 AGENT_ID.into(),
667 finished_at.as_str().into(),
668 ])?
669 .all()
670 .await?
671 .results::<ReviewNote>()?;
672 let mut on_lines = Vec::new();
673 let mut summary = String::new();
674 for note in notes {
675 match (note.path, note.line) {
676 (Some(path), Some(line)) => {
677 on_lines.push(format!("- `{path}` line {line}: {}", note.body));
678 }
679 (Some(path), None) => on_lines.push(format!("- `{path}`: {}", note.body)),
680 (None, _) => summary = note.body,
681 }
682 }
683 let mut text = format!(
684 "Another agent reviewed your change and asked for changes.\n\n{summary}"
685 );
686 if !on_lines.is_empty() {
687 text.push_str("\n\nIts comments on lines:\n");
688 text.push_str(&on_lines.join("\n"));
689 }
690 Ok(text)
691 }
692 Feedback::Person(request) => {
693 // What they wrote since the agent last revised, which their
694 // request for changes closes.
695 let revised: Option<String> = self
696 .db
697 .prepare("SELECT revised_at AS value FROM pulls WHERE id = ?")
698 .bind(&[pull.id.as_str().into()])?
699 .first::<Option<String>>(Some("value"))
700 .await?
701 .flatten();
702 let notes = self
703 .db
704 .prepare(
705 "SELECT body, path, line FROM comments
706 WHERE repo_id = ? AND number = ? AND author_id = ?
707 AND created_at > ? AND created_at <= ?
708 ORDER BY id",
709 )
710 .bind(&[
711 pull.repo_id.as_str().into(),
712 pull.number.into(),
713 request.author_id.as_str().into(),
714 revised.unwrap_or_default().into(),
715 request.created_at.as_str().into(),
716 ])?
717 .all()
718 .await?
719 .results::<ReviewNote>()?;
720 let mut on_lines = Vec::new();
721 let mut said = Vec::new();
722 for note in notes {
723 match (note.path, note.line) {
724 (Some(path), Some(line)) => {
725 on_lines.push(format!("- `{path}` line {line}: {}", note.body));
726 }
727 (Some(path), None) => on_lines.push(format!("- `{path}`: {}", note.body)),
728 (None, _) => said.push(note.body),
729 }
730 }
731 let mut text = format!(
732 "{} reviewed your change and asked for changes.\n\n{}",
733 request.author_name,
734 said.join("\n\n")
735 );
736 if !on_lines.is_empty() {
737 text.push_str("\n\nTheir comments on lines:\n");
738 text.push_str(&on_lines.join("\n"));
739 }
740 Ok(text)
741 }
742 }
743 }
744
745 pub(crate) async fn advance(&self, a: AdvanceArgs) -> Result<Advance> {
746 let Some(pull) = self.pull_by_id(&a.pull_id).await? else {
747 return Ok(Advance::None);
748 };
749 if pull.status != PullStatus::Open {
750 return Ok(Advance::None);
751 }
Agents move along on private repositories too752 // As a member: a private repository would look missing otherwise,
753 // and the pull request would never move.
754 let viewer: Viewer = self.author_viewer(&pull).await?;
Agents as a team: lifecycle, merge queue, billing and a new shell755 let repo: Outcome<Repo> = g1t_kit::call(
756 &self.repos,
757 "get_by_id",
758 &GetByIdArgs {
759 id: pull.repo_id.clone(),
760 viewer,
761 },
762 )
763 .await?;
764 let (Outcome::Ok(repo), Some(source)) = (repo, pull.fork.clone()) else {
765 return Ok(Advance::None);
766 };
767 let issue = match pull.issue {
768 Some(number) => self.issue(&pull.repo_id, number).await?,
769 None => None,
770 };
771 let behind = self.is_behind(&repo.id, &pull).await?;
772 let Some((lifecycle, next)) = self.assess(&pull, &issue, behind).await? else {
773 return Ok(Advance::None);
774 };
775
776 if matches!(next, Next::Merge) {
777 self.merge_by_policy(&repo, &pull).await?;
778 return Ok(Advance::None);
779 }
780 let (step, minutes) = match &next {
781 Next::Wait | Next::Merge => return Ok(Advance::None),
782 Next::Review => ("review", REVIEW_MINUTES),
783 Next::Revise(_) => ("revision", REVISION_MINUTES),
784 Next::CatchUp => ("catch_up", CATCH_UP_MINUTES),
785 };
786 let feedback = match &next {
787 Next::Revise(feedback) => self.feedback(&pull, feedback).await?,
788 _ => String::new(),
789 };
790 if !self
791 .claim(&pull.id, step, minutes, matches!(next, Next::Revise(_)))
792 .await?
793 {
794 return Ok(Advance::None);
795 }
796 // Said in the conversation, so nobody has to wonder why a review or
797 // a new commit appeared.
798 let told = match &next {
799 Next::Review => {
800 self.db
801 .prepare(
802 "UPDATE pulls SET reviewers = json_insert(reviewers, '$[#]', ?1)
803 WHERE id = ?2 AND NOT EXISTS (
804 SELECT 1 FROM json_each(pulls.reviewers) WHERE json_each.value = ?1)",
805 )
806 .bind(&[AGENT_NAME.into(), pull.id.as_str().into()])?
807 .run()
808 .await?;
809 "requested a review from g1t-agent".to_owned()
810 }
811 Next::Revise(Feedback::FailedChecks) => {
812 "sent g1t-agent back to fix the failed checks".to_owned()
813 }
GitHub Actions on g1t, part two: running workflows814 Next::Revise(Feedback::FailedWorkflows) => {
815 "sent g1t-agent back to fix the failed workflows".to_owned()
816 }
Agents as a team: lifecycle, merge queue, billing and a new shell817 Next::Revise(Feedback::Review(_)) => {
818 "sent g1t-agent back to address the review".to_owned()
819 }
820 _ => format!(
821 "asked g1t-agent to bring this up to date with {}",
822 repo.default_branch
823 ),
824 };
825 self.note(
826 &pull.repo_id,
827 pull.number,
828 (POLICY_ACTOR_ID, POLICY_ACTOR_NAME),
829 &told,
830 )
831 .await?;
832 let job = LifecycleJob {
833 pull_id: pull.id,
834 repo: RepoPath {
835 namespace: repo.namespace,
836 name: repo.name,
837 },
838 number: pull.number,
839 author: pull.author,
840 source,
841 branch: None,
842 default_branch: repo.default_branch,
843 title: pull.title,
844 description: pull.body.unwrap_or_default(),
845 issue,
846 feedback,
847 round: lifecycle.revisions + 1,
848 };
849 Ok(match next {
850 Next::Review => Advance::Review { job },
851 Next::Revise(_) => Advance::Revise { job },
852 Next::CatchUp => Advance::CatchUp { job },
853 Next::Wait | Next::Merge => Advance::None,
854 })
855 }
856
857 /// Lands a pull request that is ready, on the authority of the
858 /// repository's settings instead of a person's click.
859 async fn merge_by_policy(&self, repo: &Repo, pull: &Pull) -> Result<()> {
860 if !self.claim(&pull.id, "merge", MERGE_MINUTES, false).await? {
861 return Ok(());
862 }
863 // g1t acts for the workspace whose members turned this on.
864 let actor = User {
865 id: POLICY_ACTOR_ID.to_owned(),
866 username: POLICY_ACTOR_NAME.to_owned(),
867 verified: true,
868 workspaces: vec![Membership {
869 slug: repo.namespace.clone(),
870 role: Role::Member,
871 }],
872 ..User::default()
873 };
874 let merged = self
875 .merge_pull(PullActionArgs {
876 actor,
877 repo: RepoPath {
878 namespace: repo.namespace.clone(),
879 name: repo.name.clone(),
880 },
881 number: pull.number,
882 summary: String::new(),
883 keep_issue_open: false,
884 ignore_checks: false,
885 })
886 .await?;
887 match merged {
888 Outcome::Ok(_) => Ok(()),
889 // Most likely the branch moved in the moment between: let go, and
890 // the next look at it will catch up and try again.
891 Outcome::Fail(failure) if failure.code == FailureCode::Conflict => {
892 self.db
893 .prepare(
894 "UPDATE pulls SET working_on = NULL, working_until = NULL
895 WHERE id = ? AND working_on = 'merge'",
896 )
897 .bind(&[pull.id.as_str().into()])?
898 .run()
899 .await?;
900 Ok(())
901 }
902 Outcome::Fail(failure) => {
903 self.stall(StallArgs {
904 pull_id: pull.id.clone(),
905 reason: format!("g1t could not merge this: {}", failure.message),
906 })
907 .await?;
908 Ok(())
909 }
910 }
911 }
912
913 /// Records that a merge was asked for while the pull request was
914 /// behind, and announces it so that the runner brings it up to date.
915 pub(crate) async fn request_landing(
916 &self,
917 pull: &Pull,
918 actor: &User,
919 keep_issue_open: bool,
920 ) -> Result<()> {
921 let now = now_ms();
922 let request = serde_json::to_string(&LandRequest {
923 actor: actor.clone(),
924 keep_issue_open,
925 })?;
926 self.db
927 .prepare(
928 "UPDATE pulls
929 SET land_requested = ?, land_requested_at = ?, stalled = NULL,
930 working_on = 'catch_up', working_until = ?
931 WHERE id = ?",
932 )
933 .bind(&[
934 request.into(),
935 rfc3339(now).into(),
936 rfc3339(now + CATCH_UP_MINUTES * 60 * 1000).into(),
937 pull.id.as_str().into(),
938 ])?
939 .run()
940 .await?;
941 self.publish(
942 "pull.merge_requested",
943 &pull.repo_id,
944 actor,
945 PullEvent {
946 pull_id: pull.id.clone(),
947 repo_id: pull.repo_id.clone(),
948 number: pull.number,
949 issue: pull.issue,
950 ..PullEvent::default()
951 },
952 )
953 .await
954 }
955
956 /// The merge waiting on a pull request, if one was asked for recently
957 /// enough to still stand.
958 async fn land_request(&self, pull_id: &str) -> Result<Option<LandRequest>> {
959 let row = self
960 .db
961 .prepare("SELECT land_requested, land_requested_at, stalled FROM pulls WHERE id = ?")
962 .bind(&[pull_id.into()])?
963 .first::<LandRow>(None)
964 .await?;
965 let oldest = rfc3339(now_ms().saturating_sub(CATCH_UP_MINUTES * 60 * 1000));
966 Ok(row
967 .filter(|row| {
968 row.land_requested_at
969 .as_deref()
970 .is_some_and(|at| at >= oldest.as_str())
971 })
972 .and_then(|row| row.land_requested)
973 .and_then(|request| serde_json::from_str(&request).ok()))
974 }
975
976 /// Whether a merge is waiting on a pull request, and why g1t stopped
977 /// working on it if it did.
978 pub(crate) async fn landing_state(&self, pull_id: &str) -> Result<(bool, Option<String>)> {
979 let stalled = self
980 .db
981 .prepare("SELECT land_requested, land_requested_at, stalled FROM pulls WHERE id = ?")
982 .bind(&[pull_id.into()])?
983 .first::<LandRow>(None)
984 .await?
985 .and_then(|row| row.stalled);
986 Ok((self.land_request(pull_id).await?.is_some(), stalled))
987 }
988
989 async fn forget_landing(&self, pull_id: &str) -> Result<()> {
990 self.db
991 .prepare(
992 "UPDATE pulls SET land_requested = NULL, land_requested_at = NULL WHERE id = ?",
993 )
994 .bind(&[pull_id.into()])?
995 .run()
996 .await?;
997 Ok(())
998 }
999
1000 /// Lands a pull request whose head has just moved, if a merge of it was
1001 /// waiting for exactly that. What it was caught up to was already
1002 /// checked and reviewed apart from the merge, so the checks are not
1003 /// waited for again; a repository that wants them rerun turns on
1004 /// "require up to date", and then nothing is landed this way.
1005 pub(crate) async fn land_if_requested(&self, pull_id: &str) -> Result<()> {
1006 let Some(request) = self.land_request(pull_id).await? else {
1007 return Ok(());
1008 };
1009 self.forget_landing(pull_id).await?;
1010 let Some(pull) = self.pull_by_id(pull_id).await? else {
1011 return Ok(());
1012 };
1013 let repo: Outcome<Repo> = g1t_kit::call(
1014 &self.repos,
1015 "get_by_id",
1016 &GetByIdArgs {
1017 id: pull.repo_id.clone(),
1018 viewer: Some(request.actor.clone()),
1019 },
1020 )
1021 .await?;
1022 let Outcome::Ok(repo) = repo else {
1023 return Ok(());
1024 };
1025 let merged = self
1026 .merge_pull(PullActionArgs {
1027 actor: request.actor,
1028 repo: RepoPath {
1029 namespace: repo.namespace,
1030 name: repo.name,
1031 },
1032 number: pull.number,
1033 summary: String::new(),
1034 keep_issue_open: request.keep_issue_open,
1035 ignore_checks: true,
1036 })
1037 .await?;
1038 if let Outcome::Fail(failure) = merged {
1039 self.stall(StallArgs {
1040 pull_id: pull.id,
1041 reason: format!(
1042 "It was brought up to date but could not be merged: {}",
1043 failure.message
1044 ),
1045 })
1046 .await?;
1047 }
1048 Ok(())
1049 }
1050
1051 /// What the runner needs to bring a pull request up to date for a merge
1052 /// that is waiting on it.
1053 pub(crate) async fn catch_up_job(&self, a: CatchUpJobArgs) -> Result<Option<LifecycleJob>> {
1054 if self.land_request(&a.pull_id).await?.is_none() {
1055 return Ok(None);
1056 }
1057 let Some(pull) = self.pull_by_id(&a.pull_id).await? else {
1058 return Ok(None);
1059 };
1060 // Its author can read both the repository and the pull request's source.
1061 let repo: Outcome<Repo> = g1t_kit::call(
1062 &self.repos,
1063 "get_by_id",
1064 &GetByIdArgs {
1065 id: pull.repo_id.clone(),
Agents move along on private repositories too1066 viewer: self.author_viewer(&pull).await?,
Agents as a team: lifecycle, merge queue, billing and a new shell1067 },
1068 )
1069 .await?;
1070 let Outcome::Ok(repo) = repo else {
1071 return Ok(None);
1072 };
1073 let path = RepoPath {
1074 namespace: repo.namespace,
1075 name: repo.name,
1076 };
1077 let issue = match pull.issue {
1078 Some(number) => self.issue(&pull.repo_id, number).await?,
1079 None => None,
1080 };
1081 Ok(Some(LifecycleJob {
1082 pull_id: pull.id,
1083 source: pull.fork.unwrap_or_else(|| path.clone()),
1084 repo: path,
1085 number: pull.number,
1086 author: pull.author,
1087 branch: pull.branch,
1088 default_branch: repo.default_branch,
1089 title: pull.title,
1090 description: pull.body.unwrap_or_default(),
1091 issue,
1092 feedback: String::new(),
1093 round: 0,
1094 }))
1095 }
1096
1097 pub(crate) async fn stall(&self, a: StallArgs) -> Result<bool> {
1098 self.db
1099 .prepare(
1100 "UPDATE pulls
1101 SET stalled = ?, working_on = NULL, working_until = NULL,
1102 land_requested = NULL, land_requested_at = NULL
1103 WHERE id = ? AND status = 'open'",
1104 )
1105 .bind(&[a.reason.trim().into(), a.pull_id.as_str().into()])?
1106 .run()
1107 .await?;
1108 self.db
1109 .prepare("UPDATE pulls SET stage = 'needs_you', stage_detail = ? WHERE id = ?")
1110 .bind(&[a.reason.trim().into(), a.pull_id.into()])?
1111 .run()
1112 .await?;
1113 Ok(true)
1114 }
1115
1116 pub(crate) async fn managed_pulls(&self, a: ManagedPullsArgs) -> Result<Vec<String>> {
1117 let rows = self
1118 .db
1119 .prepare(
1120 "SELECT id AS value FROM pulls
1121 WHERE status = 'open' AND managed = 1 AND stalled IS NULL
1122 AND (?1 IS NULL OR repo_id = ?1)
1123 ORDER BY updated_at DESC LIMIT ?2",
1124 )
1125 .bind(&[
1126 a.repo_id.map_or(JsValue::NULL, JsValue::from),
1127 MANAGED_PAGE.into(),
1128 ])?
1129 .all()
1130 .await?
1131 .results::<ValueRow>()?;
1132 Ok(rows.into_iter().map(|row| row.value).collect())
1133 }
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138 use super::*;
1139
1140 const MAX_REVISIONS: u32 = 2;
1141
1142 #[test]
1143 fn approvals_the_repository_wants_are_waited_for() {
1144 let short = || Facts {
1145 review: reviewed(Some(Verdict::Approve)),
1146 approvals_missing: Some("This repository requires 1 approving review.".to_owned()),
1147 ..facts()
1148 };
1149 let (lifecycle, next) = decide(short());
1150 assert_eq!(lifecycle.stage, Stage::NeedsYou);
1151 assert_eq!(
1152 lifecycle.detail,
1153 "This repository requires 1 approving review."
1154 );
1155 assert!(matches!(next, Next::Wait));
1156 // Not even a repository that merges by itself merges without them.
1157 let automatic = Facts {
1158 auto_merge: true,
1159 ..short()
1160 };
1161 assert_eq!(outcome(automatic), (Stage::NeedsYou, "wait"));
1162 }
1163
1164 #[test]
1165 fn a_repository_can_leave_review_to_people() {
1166 let unreviewed = Facts {
1167 agent_review: false,
1168 ..facts()
1169 };
1170 assert_eq!(outcome(unreviewed), (Stage::Ready, "wait"));
1171 let failing = Facts {
1172 agent_review: false,
1173 check_status: Some(CheckStatus::Failed),
1174 ..facts()
1175 };
1176 assert_eq!(outcome(failing), (Stage::Revising, "revise for checks"));
1177 }
1178
1179 #[test]
1180 fn a_repository_sets_how_often_the_author_is_sent_back() {
1181 let never = Facts {
1182 max_revisions: 0,
1183 check_status: Some(CheckStatus::Failed),
1184 ..facts()
1185 };
1186 assert_eq!(outcome(never), (Stage::NeedsYou, "wait"));
1187 }
1188
1189 /// A pull request that is ready for review, with checks that passed
1190 /// and nothing else yet.
1191 fn facts() -> Facts {
1192 Facts {
1193 draft: false,
1194 stalled: None,
1195 working_on: None,
1196 check_status: Some(CheckStatus::Passed),
1197 review_pending: false,
1198 has_checks: true,
1199 revisions: 0,
1200 review: None,
1201 behind: false,
1202 auto_merge: false,
1203 require_up_to_date: false,
1204 agent_review: true,
1205 max_revisions: MAX_REVISIONS,
1206 approvals_missing: None,
1207 person_request: None,
1208 queued: None,
GitHub Actions on g1t, part two: running workflows1209 workflows: WorkflowFacts::default(),
Agents as a team: lifecycle, merge queue, billing and a new shell1210 }
1211 }
1212
1213 #[test]
GitHub Actions on g1t, part two: running workflows1214 fn failed_workflows_send_the_agent_back_and_running_ones_wait() {
1215 let failed = Facts {
1216 workflows: WorkflowFacts { pending: vec![], failed: vec!["CI / pull_request".into()] },
1217 ..facts()
1218 };
1219 let (lifecycle, next) = decide(failed);
1220 assert!(matches!(next, Next::Revise(Feedback::FailedWorkflows)));
1221 assert!(lifecycle.detail.contains("CI / pull_request failed"));
1222 let running = Facts {
1223 workflows: WorkflowFacts { pending: vec!["CI / pull_request".into()], failed: vec![] },
1224 ..facts()
1225 };
1226 let (lifecycle, next) = decide(running);
1227 assert!(matches!(next, Next::Wait));
1228 assert!(lifecycle.detail.contains("Waiting for CI / pull_request"));
1229 }
1230
1231 #[test]
Agents as a team: lifecycle, merge queue, billing and a new shell1232 fn a_queued_pull_request_waits_in_the_queue() {
1233 let queued = Facts {
1234 review: reviewed(Some(Verdict::Approve)),
1235 queued: Some((QueueState::Testing, vec![12, 14])),
1236 auto_merge: true,
1237 ..facts()
1238 };
1239 let (lifecycle, next) = decide(queued);
1240 assert_eq!(lifecycle.stage, Stage::Queued);
1241 assert!(lifecycle.detail.contains("#12, #14"));
1242 assert!(matches!(next, Next::Wait));
1243 }
1244
1245 fn asked_by_a_person() -> Option<PersonRequest> {
1246 Some(PersonRequest {
1247 author_id: "usr_reviewer".to_owned(),
1248 author_name: "g1t-reviewer".to_owned(),
1249 created_at: "2026-10-02T11:00:00.000Z".to_owned(),
1250 })
1251 }
1252
1253 #[test]
1254 fn a_person_asking_for_changes_sends_the_agent_back() {
1255 let asked = Facts {
1256 review: reviewed(Some(Verdict::Approve)),
1257 approvals_missing: Some("A reviewer has asked for changes.".to_owned()),
1258 person_request: asked_by_a_person(),
1259 ..facts()
1260 };
1261 let (lifecycle, _) = decide(Facts {
1262 person_request: asked_by_a_person(),
1263 ..facts()
1264 });
1265 assert!(lifecycle.detail.starts_with("g1t-reviewer asked for changes"));
1266 assert_eq!(outcome(asked), (Stage::Revising, "revise for a person"));
1267 }
1268
1269 #[test]
1270 fn a_person_is_asked_once_the_revisions_run_out() {
1271 let exhausted = Facts {
1272 person_request: asked_by_a_person(),
1273 revisions: MAX_REVISIONS,
1274 ..facts()
1275 };
1276 assert_eq!(outcome(exhausted), (Stage::NeedsYou, "wait"));
1277 }
1278
1279 fn reviewed(verdict: Option<Verdict>) -> Option<FinishedReview> {
1280 Some(FinishedReview {
1281 finished_at: "2026-10-02T10:00:00.000Z".to_owned(),
1282 verdict,
1283 })
1284 }
1285
1286 /// The stage, and a word for the step to take.
1287 fn outcome(facts: Facts) -> (Stage, &'static str) {
1288 let (lifecycle, next) = decide(facts);
1289 let step = match next {
1290 Next::Wait => "wait",
1291 Next::Review => "review",
1292 Next::Revise(Feedback::FailedChecks) => "revise for checks",
GitHub Actions on g1t, part two: running workflows1293 Next::Revise(Feedback::FailedWorkflows) => "revise for workflows",
Agents as a team: lifecycle, merge queue, billing and a new shell1294 Next::Revise(Feedback::Review(_)) => "revise for review",
1295 Next::Revise(Feedback::Person(_)) => "revise for a person",
1296 Next::CatchUp => "catch up",
1297 Next::Merge => "merge",
1298 };
1299 (lifecycle.stage, step)
1300 }
1301
1302 #[test]
1303 fn nothing_is_started_while_the_agent_is_still_working() {
1304 let draft = Facts {
1305 draft: true,
1306 check_status: None,
1307 ..facts()
1308 };
1309 assert_eq!(outcome(draft), (Stage::Working, "wait"));
1310 }
1311
1312 #[test]
1313 fn checks_come_before_review() {
1314 let unchecked = Facts {
1315 check_status: None,
1316 ..facts()
1317 };
1318 assert_eq!(outcome(unchecked), (Stage::Checking, "wait"));
1319 let running = Facts {
1320 check_status: Some(CheckStatus::Running),
1321 ..facts()
1322 };
1323 assert_eq!(outcome(running), (Stage::Checking, "wait"));
1324 assert_eq!(outcome(facts()), (Stage::Reviewing, "review"));
1325 }
1326
1327 #[test]
1328 fn an_issue_without_checks_goes_straight_to_review() {
1329 let unchecked = Facts {
1330 has_checks: false,
1331 check_status: None,
1332 ..facts()
1333 };
1334 assert_eq!(outcome(unchecked), (Stage::Reviewing, "review"));
1335 }
1336
1337 #[test]
1338 fn failed_checks_send_the_author_back() {
1339 let failed = Facts {
1340 check_status: Some(CheckStatus::Failed),
1341 ..facts()
1342 };
1343 assert_eq!(outcome(failed), (Stage::Revising, "revise for checks"));
1344 }
1345
1346 #[test]
1347 fn checks_that_could_not_run_are_a_persons_problem() {
1348 let errored = Facts {
1349 check_status: Some(CheckStatus::Errored),
1350 ..facts()
1351 };
1352 assert_eq!(outcome(errored), (Stage::NeedsYou, "wait"));
1353 }
1354
1355 #[test]
1356 fn a_review_asking_for_changes_sends_the_author_back() {
1357 let changes = Facts {
1358 review: reviewed(Some(Verdict::RequestChanges)),
1359 ..facts()
1360 };
1361 assert_eq!(outcome(changes), (Stage::Revising, "revise for review"));
1362 }
1363
1364 #[test]
1365 fn the_author_is_sent_back_only_so_many_times() {
1366 let failing = Facts {
1367 check_status: Some(CheckStatus::Failed),
1368 revisions: MAX_REVISIONS,
1369 ..facts()
1370 };
1371 assert_eq!(outcome(failing), (Stage::NeedsYou, "wait"));
1372 let unconvinced = Facts {
1373 review: reviewed(Some(Verdict::RequestChanges)),
1374 revisions: MAX_REVISIONS,
1375 ..facts()
1376 };
1377 assert_eq!(outcome(unconvinced), (Stage::NeedsYou, "wait"));
1378 // One short of the limit still gets another go.
1379 let once = Facts {
1380 check_status: Some(CheckStatus::Failed),
1381 revisions: MAX_REVISIONS - 1,
1382 ..facts()
1383 };
1384 assert_eq!(outcome(once), (Stage::Revising, "revise for checks"));
1385 }
1386
1387 #[test]
1388 fn a_review_that_could_not_be_written_is_not_retried() {
1389 let broken = Facts {
1390 review: reviewed(None),
1391 ..facts()
1392 };
1393 assert_eq!(outcome(broken), (Stage::NeedsYou, "wait"));
1394 }
1395
1396 #[test]
1397 fn being_behind_only_holds_a_change_up_where_the_repository_says_so() {
1398 let behind = || Facts {
1399 review: reviewed(Some(Verdict::Approve)),
1400 behind: true,
1401 ..facts()
1402 };
1403 // By default it is ready as it is; merging brings it up to date.
1404 assert_eq!(outcome(behind()), (Stage::Ready, "wait"));
1405 let strict = Facts {
1406 require_up_to_date: true,
1407 ..behind()
1408 };
1409 assert_eq!(outcome(strict), (Stage::CatchingUp, "catch up"));
1410 let current = Facts {
1411 review: reviewed(Some(Verdict::Approve)),
1412 ..facts()
1413 };
1414 assert_eq!(outcome(current), (Stage::Ready, "wait"));
1415 }
1416
1417 #[test]
1418 fn a_ready_change_lands_by_itself_only_where_the_repository_says_so() {
1419 let ready = || Facts {
1420 review: reviewed(Some(Verdict::Approve)),
1421 ..facts()
1422 };
1423 assert_eq!(outcome(ready()), (Stage::Ready, "wait"));
1424 let automatic = Facts {
1425 auto_merge: true,
1426 ..ready()
1427 };
1428 assert_eq!(outcome(automatic), (Stage::Ready, "merge"));
1429 // One that is behind is merged too: merging brings it up to date.
1430 let behind = Facts {
1431 auto_merge: true,
1432 behind: true,
1433 ..ready()
1434 };
1435 assert_eq!(outcome(behind), (Stage::Ready, "merge"));
1436 // Unless the repository wants it caught up and checked again first.
1437 let strict = Facts {
1438 auto_merge: true,
1439 behind: true,
1440 require_up_to_date: true,
1441 ..ready()
1442 };
1443 assert_eq!(outcome(strict), (Stage::CatchingUp, "catch up"));
1444 // Nothing short of approved is merged, whatever the setting.
1445 let failing = Facts {
1446 auto_merge: true,
1447 check_status: Some(CheckStatus::Failed),
1448 ..ready()
1449 };
1450 assert_eq!(outcome(failing), (Stage::Revising, "revise for checks"));
1451 let unreviewed = Facts {
1452 auto_merge: true,
1453 ..facts()
1454 };
1455 assert_eq!(outcome(unreviewed), (Stage::Reviewing, "review"));
1456 }
1457
1458 #[test]
1459 fn catching_up_reruns_the_checks_but_not_the_review() {
1460 // The merge moved the head, so the checks are waited for again.
1461 let merged_in = Facts {
1462 review: reviewed(Some(Verdict::Approve)),
1463 check_status: None,
1464 ..facts()
1465 };
1466 assert_eq!(outcome(merged_in), (Stage::Checking, "wait"));
1467 }
1468
1469 #[test]
1470 fn a_step_under_way_is_not_started_again() {
1471 for (step, stage) in [
1472 ("review", Stage::Reviewing),
1473 ("revision", Stage::Revising),
1474 ("catch_up", Stage::CatchingUp),
1475 ] {
1476 let busy = Facts {
1477 working_on: Some(step.to_owned()),
1478 // Whatever else is true, the step in hand comes first.
1479 check_status: Some(CheckStatus::Failed),
1480 ..facts()
1481 };
1482 assert_eq!(outcome(busy), (stage, "wait"));
1483 }
1484 let asked = Facts {
1485 review_pending: true,
1486 ..facts()
1487 };
1488 assert_eq!(outcome(asked), (Stage::Reviewing, "wait"));
1489 }
1490
1491 #[test]
1492 fn once_stopped_it_stays_stopped() {
1493 let (lifecycle, next) = decide(Facts {
1494 stalled: Some("The agent could not catch up.".to_owned()),
1495 review: reviewed(Some(Verdict::Approve)),
1496 behind: true,
1497 ..facts()
1498 });
1499 assert_eq!(lifecycle.stage, Stage::NeedsYou);
1500 assert_eq!(lifecycle.detail, "The agent could not catch up.");
1501 assert!(matches!(next, Next::Wait));
1502 }
1503}