g1t/services/work/src/lifecycle.rs

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