flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/work/src/lifecycle.rs

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