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