g1t/services/work/src/lifecycle.rs

1,882 lines72,606 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
Fast pages, required checks on the branch, self-hosted runners, honest incidents2//! takes each remaining step itself: waiting for the checks the
3//! repository's workflows report on it, a review by another agent, sending
4//! the author back to address a failed check (with what its jobs printed)
5//! or the review, and catching up when the branch it would land on has
6//! moved. It stops when the pull request meets everything the default
7//! branch's protection requires, or when it has tried and a person has to
8//! decide.
Agents as a team: lifecycle, merge queue, billing and a new shell9//!
10//! This service decides what the next step is and claims it. The runner
11//! service asks, on every event that could change the answer, and carries
12//! the step out in a sandbox.
13
14use g1t_contracts::repos::{GetByIdArgs, Repo, RepoPath};
15use g1t_contracts::time::rfc3339;
16use g1t_contracts::work::*;
Workspace names and icons, and a component kit for every control17use g1t_contracts::{FailureCode, Membership, Outcome, User, Viewer};
Agents as a team: lifecycle, merge queue, billing and a new shell18use g1t_kit::now_ms;
19use std::collections::HashMap;
20
21use serde::Deserialize;
22use worker::Result;
23use worker::wasm_bindgen::JsValue;
24
25use crate::Work;
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily26use crate::prefetch::Slot;
Agents as a team: lifecycle, merge queue, billing and a new shell27use crate::reviews::{AGENT_ID, AGENT_NAME};
GitHub Actions on g1t, part two: running workflows28use crate::statuses::{self, WorkflowFacts};
Agents as a team: lifecycle, merge queue, billing and a new shell29use crate::rows::ValueRow;
30
31/// How long a claimed step is waited for before it may be taken again.
32const REVIEW_MINUTES: u64 = 20;
33const REVISION_MINUTES: u64 = 60;
34const CATCH_UP_MINUTES: u64 = 30;
35const MERGE_MINUTES: u64 = 2;
36/// Who a merge made by a repository's settings is attributed to. Not an
37/// account: `g1t` cannot be registered.
A stalled pull request picks back up when its workflows pass38pub(crate) const POLICY_ACTOR_ID: &str = "g1t_policy";
39pub(crate) const POLICY_ACTOR_NAME: &str = "g1t";
Agents as a team: lifecycle, merge queue, billing and a new shell40/// How much of a failed check's output the author is shown.
41const MAX_CHECK_OUTPUT_CHARS: usize = 4_000;
Fast pages, required checks on the branch, self-hosted runners, honest incidents42/// How many failed jobs' logs the author is shown, and how much of each.
43const MAX_FAILED_JOBS: usize = 3;
44const MAX_JOB_LOG_CHARS: usize = 3_000;
45/// The most pages of a job's log read to find its end.
46const MAX_LOG_PAGES: usize = 6;
Agents as a team: lifecycle, merge queue, billing and a new shell47const MANAGED_PAGE: u32 = 200;
48
49/// The part of a pull request's row that tracks its lifecycle.
50#[derive(Deserialize)]
51struct Progress {
52 managed: u8,
53 revisions: u32,
54 revised_at: Option<String>,
55 working_on: Option<String>,
56 working_until: Option<String>,
57 stalled: Option<String>,
58}
59
60/// A merge asked for while the pull request was behind, as stored.
61#[derive(serde::Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63struct LandRequest {
64 actor: User,
65 keep_issue_open: bool,
66}
67
68#[derive(Deserialize)]
69struct LandRow {
70 land_requested: Option<String>,
71 land_requested_at: Option<String>,
72 stalled: Option<String>,
73}
74
75#[derive(Deserialize)]
76struct FinishedReview {
77 finished_at: String,
78 verdict: Option<Verdict>,
79}
80
81#[derive(Deserialize)]
82struct ReviewNote {
83 body: String,
84 path: Option<String>,
85 line: Option<u32>,
86}
87
88/// What the author is being sent back to address.
89pub(crate) enum Feedback {
Fast pages, required checks on the branch, self-hosted runners, honest incidents90 /// The merge queue took it out: its combined state failed.
Agents as a team: lifecycle, merge queue, billing and a new shell91 FailedChecks,
Fast pages, required checks on the branch, self-hosted runners, honest incidents92 /// Checks that failed on its head.
GitHub Actions on g1t, part two: running workflows93 FailedWorkflows,
Agents as a team: lifecycle, merge queue, billing and a new shell94 /// The review that finished at this time.
95 Review(String),
96 /// What a person who asked for changes wrote since the last revision.
97 Person(PersonRequest),
98}
99
100/// A person's request for changes that still stands: their latest verdict,
101/// made after the agent last revised.
102#[derive(Clone, Debug, Deserialize)]
103pub(crate) struct PersonRequest {
104 author_id: String,
105 author_name: String,
106 created_at: String,
107}
108
109/// What should happen next, if it is g1t's turn.
110pub(crate) enum Next {
111 /// A step is under way, or it is a person's turn.
112 Wait,
113 Review,
114 Revise(Feedback),
115 CatchUp,
116 /// Land it, because the repository says ready pull requests land.
117 Merge,
118}
119
120/// Whether g1t made this pull request, and so sees it through.
121pub(crate) fn made_by_g1t(pull: &Pull) -> bool {
122 pull.runtime == Runtime::Hosted && pull.agent == AGENT_NAME && pull.fork.is_some()
123}
124
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights125/// Whether a verdict by `reviewer` is a person's other than the pull
126/// request's owner (whoever asked g1t for it, or its author): neither
127/// theirs nor g1t's agent's.
128pub(crate) fn from_someone_else(pull: &Pull, reviewer: &str) -> bool {
129 !pull.is_owned_by(reviewer) && reviewer != AGENT_ID
130}
131
Agents as a team: lifecycle, merge queue, billing and a new shell132fn at(stage: Stage, detail: impl Into<String>, revisions: u32) -> Lifecycle {
133 Lifecycle {
134 stage,
135 detail: detail.into(),
136 revisions,
137 }
138}
139
140fn times(count: u32) -> String {
141 match count {
142 1 => "once".to_owned(),
143 2 => "twice".to_owned(),
144 count => format!("{count} times"),
145 }
146}
147
148/// Everything the next step depends on.
149struct Facts {
150 /// Still being made: not yet marked ready for review.
151 draft: bool,
152 /// Why g1t stopped, if it has.
153 stalled: Option<String>,
154 /// The step under way, if one was claimed and is still being waited for.
155 working_on: Option<String>,
Fast pages, required checks on the branch, self-hosted runners, honest incidents156 /// `failed` when the merge queue took it out.
Agents as a team: lifecycle, merge queue, billing and a new shell157 check_status: Option<CheckStatus>,
158 /// A review someone asked for is being written.
159 review_pending: bool,
160 revisions: u32,
161 /// The latest finished review of the change as it is now.
162 review: Option<FinishedReview>,
163 /// Whether the branch it would land on has moved without it.
164 behind: bool,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains165 /// Whether it is known to conflict with the branch it would land on.
166 conflicting: bool,
Agents as a team: lifecycle, merge queue, billing and a new shell167 /// Whether the repository lands a ready pull request by itself.
168 auto_merge: bool,
169 /// Whether the repository refuses to merge one that is behind.
170 require_up_to_date: bool,
171 /// Whether a second agent reviews it without being asked.
172 agent_review: bool,
173 /// How many times the author may be sent back.
174 max_revisions: u32,
175 /// What the repository's approval rule still wants, if anything.
176 approvals_missing: Option<String>,
177 /// A person asked for changes since the agent last revised.
178 person_request: Option<PersonRequest>,
179 /// Its place in the merge queue, and what is ahead of it there.
180 queued: Option<(QueueState, Vec<u32>)>,
Fast pages, required checks on the branch, self-hosted runners, honest incidents181 /// What the checks on its head say, against the required ones.
GitHub Actions on g1t, part two: running workflows182 workflows: WorkflowFacts,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step183 /// Why the agent's change has low confidence, when the repository asks
184 /// a person before merging one and no person has approved it since.
185 low_confidence: Option<String>,
Agents as a team: lifecycle, merge queue, billing and a new shell186}
187
188/// Where a pull request stands, and the step to take if it is g1t's turn.
189///
190/// The order is: nothing while a step is under way; a person asking for
Fast pages, required checks on the branch, self-hosted runners, honest incidents191/// changes is answered first; its checks must finish, and the required
192/// ones pass; then a review must approve; then it must be up to date. A
193/// person's or an agent's request for changes, or a failed check, sends the
194/// author back, a limited number of times, after which a person is asked.
195/// A check the branch does not require stops holding it once the
196/// revisions run out.
Agents as a team: lifecycle, merge queue, billing and a new shell197fn decide(facts: Facts) -> (Lifecycle, Next) {
198 let revisions = facts.revisions;
199 let wait = |stage, detail: &str| (at(stage, detail, revisions), Next::Wait);
200 let exhausted = revisions >= facts.max_revisions;
201
202 if facts.draft {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent203 return wait(Stage::Working, "g1t is making the change.");
Agents as a team: lifecycle, merge queue, billing and a new shell204 }
205 if let Some(reason) = &facts.stalled {
206 return wait(Stage::NeedsYou, reason);
207 }
208 if let Some((state, ahead)) = &facts.queued {
209 let named = ahead.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", ");
210 let detail = match (state, ahead.is_empty()) {
211 (QueueState::Testing, true) => "In the merge queue: being tested on the default branch as it is.".to_owned(),
212 (QueueState::Testing, false) => {
213 format!("In the merge queue: being tested together with {named}, ahead of it.")
214 }
215 (QueueState::Passed, true) => "Passed in the merge queue. Landing.".to_owned(),
216 (QueueState::Passed, false) => {
217 format!("Passed in the merge queue together with {named}. It lands once they have.")
218 }
219 _ => "In the merge queue, waiting for its turn to be tested.".to_owned(),
220 };
221 return wait(Stage::Queued, &detail);
222 }
223 match facts.working_on.as_deref() {
224 Some("revision") => {
225 return wait(
226 Stage::Revising,
227 "The agent is addressing what the checks or the review found.",
228 );
229 }
230 Some("catch_up") => {
231 return wait(
232 Stage::CatchingUp,
233 "The agent is merging in the branch this will land on, which has moved.",
234 );
235 }
Agents asked while not at work are woken to answer236 Some("answer") => {
237 return wait(
238 Stage::Answering,
239 "The agent is answering what another agent asked it.",
240 );
241 }
Agents as a team: lifecycle, merge queue, billing and a new shell242 Some("merge") => return wait(Stage::Ready, "Merging."),
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent243 Some(_) => return wait(Stage::Reviewing, "g1t is reviewing the change."),
Agents as a team: lifecycle, merge queue, billing and a new shell244 None => {}
245 }
246 if facts.review_pending {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent247 return wait(Stage::Reviewing, "g1t is reviewing the change.");
Agents as a team: lifecycle, merge queue, billing and a new shell248 }
249 // A person asked for changes: the agent makes them, as it would for a
250 // review it asked for, before anything else.
251 if let Some(request) = &facts.person_request {
252 if exhausted {
253 return wait(
254 Stage::NeedsYou,
255 &format!(
256 "{} asked for changes, and the agent has already revised {}.",
257 request.author_name,
258 times(revisions)
259 ),
260 );
261 }
262 return (
263 at(
264 Stage::Revising,
265 format!(
266 "{} asked for changes. The agent is being sent back to make them.",
267 request.author_name
268 ),
269 revisions,
270 ),
271 Next::Revise(Feedback::Person(request.clone())),
272 );
273 }
274
Fast pages, required checks on the branch, self-hosted runners, honest incidents275 // The merge queue took it out: its change failed together with what
276 // was ahead of it.
277 match facts.check_status {
278 Some(CheckStatus::Failed) if exhausted => {
279 return wait(
280 Stage::NeedsYou,
281 &format!("It failed in the merge queue after the agent revised {}.", times(revisions)),
282 );
Agents as a team: lifecycle, merge queue, billing and a new shell283 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents284 Some(CheckStatus::Failed) => {
285 return (
286 at(
287 Stage::Revising,
288 "It failed in the merge queue. The agent is being sent back to fix it.",
289 revisions,
290 ),
291 Next::Revise(Feedback::FailedChecks),
292 );
293 }
294 Some(CheckStatus::Errored) => {
295 return wait(Stage::NeedsYou, "Its checks could not be run.");
296 }
297 _ => {}
Agents as a team: lifecycle, merge queue, billing and a new shell298 }
299
Fast pages, required checks on the branch, self-hosted runners, honest incidents300 // Its checks: the agent fixes any that failed. Once it is out of
301 // revisions, only the checks the branch requires still hold it.
GitHub Actions on g1t, part two: running workflows302 if !facts.workflows.failed.is_empty() {
303 let failed = statuses::list(&facts.workflows.failed);
Fast pages, required checks on the branch, self-hosted runners, honest incidents304 let required = facts.workflows.required_failed();
305 if !exhausted {
306 return (
307 at(
308 Stage::Revising,
309 format!("{failed} failed. The agent is being sent back to fix it."),
310 revisions,
311 ),
312 Next::Revise(Feedback::FailedWorkflows),
313 );
314 }
315 if !required.is_empty() {
GitHub Actions on g1t, part two: running workflows316 return wait(
317 Stage::NeedsYou,
Fast pages, required checks on the branch, self-hosted runners, honest incidents318 &format!(
319 "The required {} {} still {} after the agent revised {}.",
320 if required.len() == 1 { "check" } else { "checks" },
321 statuses::list(&required),
322 if required.len() == 1 { "fails" } else { "fail" },
323 times(revisions)
324 ),
GitHub Actions on g1t, part two: running workflows325 );
326 }
327 }
328 if !facts.workflows.pending.is_empty() {
329 return wait(
330 Stage::Checking,
331 &format!("Waiting for {} to finish.", statuses::list(&facts.workflows.pending)),
332 );
333 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents334 let expected = facts.workflows.expected();
335 if !expected.is_empty() {
336 return wait(
337 Stage::Checking,
338 &format!(
339 "Waiting for the required {} {} to report on its latest commit.",
340 if expected.len() == 1 { "check" } else { "checks" },
341 statuses::list(&expected)
342 ),
343 );
344 }
GitHub Actions on g1t, part two: running workflows345
Agents as a team: lifecycle, merge queue, billing and a new shell346 // A second agent reviews it, unless the repository leaves review to people.
347 if facts.agent_review {
348 match facts.review {
349 None => {
350 return (
351 at(
352 Stage::Reviewing,
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent353 "g1t is about to review the change.",
Agents as a team: lifecycle, merge queue, billing and a new shell354 revisions,
355 ),
356 Next::Review,
357 );
358 }
359 Some(FinishedReview { verdict: None, .. }) => {
360 return wait(Stage::NeedsYou, "The review could not be completed.");
361 }
362 Some(FinishedReview {
363 verdict: Some(Verdict::RequestChanges),
364 ..
365 }) if exhausted => {
366 return wait(
367 Stage::NeedsYou,
368 &format!(
369 "The review still asks for changes after the agent revised {}.",
370 times(revisions)
371 ),
372 );
373 }
374 Some(FinishedReview {
375 verdict: Some(Verdict::RequestChanges),
376 finished_at,
377 }) => {
378 return (
379 at(
380 Stage::Revising,
381 "The review asked for changes. The agent is being sent back to make them.",
382 revisions,
383 ),
384 Next::Revise(Feedback::Review(finished_at)),
385 );
386 }
387 Some(FinishedReview {
388 verdict: Some(Verdict::Approve),
389 ..
390 }) => {}
391 }
392 }
393
Agents and memory, checks and conflicts, profiles, slug renames, custom domains394 // A conflict found ahead of time is resolved before anything else that
395 // is left: it could not merge, by a person or by the queue, until then.
396 if facts.conflicting {
397 return (
398 at(
399 Stage::CatchingUp,
400 "It conflicts with the branch it will land on. The agent is merging that branch in and resolving the conflicts.",
401 revisions,
402 ),
403 Next::CatchUp,
404 );
405 }
Agents as a team: lifecycle, merge queue, billing and a new shell406 // Only where the repository insists is catching up a step of its own,
Fast pages, required checks on the branch, self-hosted runners, honest incidents407 // followed by its checks again. Elsewhere it happens as part of merging.
Agents as a team: lifecycle, merge queue, billing and a new shell408 if facts.behind && facts.require_up_to_date {
409 return (
410 at(
411 Stage::CatchingUp,
412 "The branch it will land on has moved. The agent is catching up.",
413 revisions,
414 ),
415 Next::CatchUp,
416 );
417 }
418 // The repository wants approvals this does not have yet: people's turn,
419 // so it is shown as needing someone, not as g1t still working.
420 if let Some(missing) = &facts.approvals_missing {
421 return wait(Stage::NeedsYou, missing);
422 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step423 // Everything else is met, but g1t is not sure of the change: a person
424 // decides, rather than auto-merge or the queue.
425 if let Some(reasons) = &facts.low_confidence {
426 return wait(
427 Stage::NeedsYou,
428 &format!(
429 "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."
430 ),
431 );
432 }
Agents as a team: lifecycle, merge queue, billing and a new shell433 if facts.auto_merge {
434 return (
435 at(
436 Stage::Ready,
437 "Everything this repository asks for is met. Merging, as its settings say.",
438 revisions,
439 ),
440 Next::Merge,
441 );
442 }
443 wait(
444 Stage::Ready,
445 if facts.behind {
446 "Ready to merge. Merging brings it up to date with the default branch first."
447 } else {
448 "Everything this repository asks for is met. Ready to merge."
449 },
450 )
451}
452
453impl Work {
454 /// Where a pull request stands and what g1t does next, remembered so
455 /// lists can show it without working it out again. `None` for one g1t
456 /// is not seeing through.
457 pub(crate) async fn assess(
458 &self,
459 pull: &Pull,
460 issue: &Option<Issue>,
461 behind: bool,
462 ) -> Result<Option<(Lifecycle, Next)>> {
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step463 Ok(self
464 .assess_with_confidence(pull, issue, behind)
465 .await?
466 .map(|(lifecycle, next, _)| (lifecycle, next)))
467 }
468
469 /// [`Self::assess`], with how sure g1t is of the change once the agent
470 /// has finished it.
471 pub(crate) async fn assess_with_confidence(
472 &self,
473 pull: &Pull,
474 issue: &Option<Issue>,
475 behind: bool,
476 ) -> Result<Option<(Lifecycle, Next, Option<Confidence>)>> {
Agents as a team: lifecycle, merge queue, billing and a new shell477 let assessed = self.assess_now(pull, issue, behind).await?;
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily478 if let Some((lifecycle, _, _)) = &assessed
479 && !self.remembered_as(&pull.id, lifecycle)?
480 {
Agents as a team: lifecycle, merge queue, billing and a new shell481 self.remember(&pull.id, lifecycle).await?;
482 }
483 Ok(assessed)
484 }
485
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily486 /// Whether the row read for this request already says `lifecycle`, so
487 /// that showing a pull request does not write it again unchanged.
488 fn remembered_as(&self, pull_id: &str, lifecycle: &Lifecycle) -> Result<bool> {
489 let Some(found) = self.prefetched_pull(pull_id) else {
490 return Ok(false);
491 };
492 Ok(found
493 .first::<crate::rows::Snapshot>(Slot::Pull)?
494 .is_some_and(|stored| {
495 stored.stage == Some(lifecycle.stage) && stored.stage_detail.as_deref() == Some(lifecycle.detail.as_str())
496 }))
497 }
498
Agents as a team: lifecycle, merge queue, billing and a new shell499 /// Saves where a pull request stands, for [`Self::remembered`].
500 pub(crate) async fn remember(&self, pull_id: &str, lifecycle: &Lifecycle) -> Result<()> {
501 let stage = serde_json::to_value(lifecycle.stage)?;
502 self.db
503 .prepare("UPDATE pulls SET stage = ?, stage_detail = ? WHERE id = ?")
504 .bind(&[
505 stage.as_str().unwrap_or_default().into(),
506 lifecycle.detail.as_str().into(),
507 pull_id.into(),
508 ])?
509 .run()
510 .await?;
511 Ok(())
512 }
513
514 async fn assess_now(
515 &self,
516 pull: &Pull,
Fast pages, required checks on the branch, self-hosted runners, honest incidents517 // What done means is in its body, for the agent; the merge waits on
518 // the branch's required checks, not on the issue.
519 _issue: &Option<Issue>,
Agents as a team: lifecycle, merge queue, billing and a new shell520 behind: bool,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step521 ) -> Result<Option<(Lifecycle, Next, Option<Confidence>)>> {
Agents as a team: lifecycle, merge queue, billing and a new shell522 if !pull.status.is_active() {
523 return Ok(None);
524 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily525 // As read for this request (prefetch.rs), or now.
526 let prefetched = self.prefetched_pull(&pull.id);
527 let progress = match &prefetched {
528 Some(found) => found.first::<Progress>(Slot::Pull)?,
529 None => {
530 self.db
531 .prepare(
532 "SELECT managed, revisions, revised_at, working_on, working_until, stalled
533 FROM pulls WHERE id = ?",
534 )
535 .bind(&[pull.id.as_str().into()])?
536 .first::<Progress>(None)
537 .await?
538 }
539 };
540 let Some(progress) = progress.filter(|progress| progress.managed != 0) else {
Agents as a team: lifecycle, merge queue, billing and a new shell541 return Ok(None);
542 };
543 let now = rfc3339(now_ms());
544 let working_on = progress
545 .working_until
546 .as_deref()
547 .is_some_and(|until| until > now.as_str())
548 .then(|| progress.working_on.clone().unwrap_or_default());
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily549 let review = match &prefetched {
550 Some(found) => found.first::<FinishedReview>(Slot::Review)?,
551 None => {
552 self.db
553 .prepare(
554 "SELECT finished_at, verdict FROM review_runs
555 WHERE pull_id = ? AND finished_at IS NOT NULL ORDER BY id DESC LIMIT 1",
556 )
557 .bind(&[pull.id.as_str().into()])?
558 .first::<FinishedReview>(None)
559 .await?
560 }
561 }
Agents as a team: lifecycle, merge queue, billing and a new shell562 // A review of what the change was before its last revision says
563 // nothing about what it is now.
564 .filter(|review| {
565 progress
566 .revised_at
567 .as_deref()
568 .is_none_or(|revised| review.finished_at.as_str() >= revised)
569 });
570 let settings = self.settings(&pull.repo_id).await?;
Fast pages, required checks on the branch, self-hosted runners, honest incidents571 let workflows = WorkflowFacts::of(
572 &self.statuses(&pull.repo_id, pull.head_commit.as_deref()).await?,
573 &settings.required_checks,
574 );
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step575 // Once the agent has finished the change: how sure g1t is of it, and
576 // whether that holds it for a person.
577 let (confidence, low_confidence) = if pull.status == PullStatus::Draft {
578 (None, None)
579 } else {
580 let review_comments = match &review {
581 Some(review) => self.review_comments(pull, &review.finished_at).await?,
582 None => 0,
583 };
584 let confidence = self
585 .assess_confidence(
586 pull,
587 crate::confidence::Signals {
Fast pages, required checks on the branch, self-hosted runners, honest incidents588 required: crate::confidence::RequiredSignal::of(&workflows.required),
589 queue_failed: pull.check_status == Some(CheckStatus::Failed),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step590 revisions: progress.revisions,
591 agent_review: settings.agent_review,
592 review: review.as_ref().and_then(|review| review.verdict),
593 review_comments,
594 ..Default::default()
595 },
596 )
597 .await?;
598 let held = settings.hold_low_confidence
599 && confidence.level == ConfidenceLevel::Low
600 && !self.person_approved(pull, progress.revised_at.as_deref()).await?;
601 let reasons = held.then(|| confidence.reasons.join(", "));
602 (Some(confidence), reasons)
603 };
604 let (lifecycle, next) = decide(Facts {
Agents as a team: lifecycle, merge queue, billing and a new shell605 draft: pull.status == PullStatus::Draft,
606 stalled: progress.stalled,
607 working_on,
608 check_status: pull.check_status,
609 review_pending: self.review_pending(&pull.id).await?,
610 revisions: progress.revisions,
611 review,
612 behind,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains613 conflicting: self.conflicting_files(pull).await?.is_some(),
Agents as a team: lifecycle, merge queue, billing and a new shell614 auto_merge: settings.auto_merge,
615 require_up_to_date: settings.require_up_to_date,
616 agent_review: settings.agent_review,
617 max_revisions: settings.max_revisions,
618 approvals_missing: self.approvals_gap(&settings, pull).await?,
619 person_request: self
620 .person_request(pull, progress.revised_at.as_deref())
621 .await?,
622 queued: self.queued_entry(&pull.id).await?,
Fast pages, required checks on the branch, self-hosted runners, honest incidents623 workflows,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step624 low_confidence,
625 });
626 Ok(Some((lifecycle, next, confidence)))
Agents as a team: lifecycle, merge queue, billing and a new shell627 }
628
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights629 /// The latest request for changes by a person other than its owner
630 /// (whoever asked g1t for it, or its author), if it is that person's
631 /// latest verdict and came after the last revision.
Agents as a team: lifecycle, merge queue, billing and a new shell632 async fn person_request(
633 &self,
634 pull: &Pull,
635 revised_at: Option<&str>,
636 ) -> Result<Option<PersonRequest>> {
637 #[derive(Deserialize)]
638 struct Verdicts {
639 author_id: String,
640 author_name: String,
641 verdict: Verdict,
642 created_at: String,
643 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily644 let rows = match self.prefetched_pull(&pull.id) {
645 Some(found) => found
646 .rows::<Verdicts>(Slot::Verdicts)?
647 .into_iter()
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights648 .filter(|row| from_someone_else(pull, &row.author_id))
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily649 .collect(),
650 None => self
651 .db
652 .prepare(
653 "SELECT author_id, author_name, verdict, created_at FROM comments
654 WHERE repo_id = ? AND number = ? AND verdict IS NOT NULL
655 AND author_id != ? AND author_id != ?
656 ORDER BY id",
657 )
658 .bind(&[
659 pull.repo_id.as_str().into(),
660 pull.number.into(),
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights661 pull.owner().id.as_str().into(),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily662 AGENT_ID.into(),
663 ])?
664 .all()
665 .await?
666 .results::<Verdicts>()?,
667 };
Agents as a team: lifecycle, merge queue, billing and a new shell668 // Each person's latest verdict is the one that stands.
669 let mut latest: HashMap<String, Verdicts> = HashMap::new();
670 for row in rows {
671 latest.insert(row.author_id.clone(), row);
672 }
673 Ok(latest
674 .into_values()
675 .filter(|row| row.verdict == Verdict::RequestChanges)
676 .filter(|row| revised_at.is_none_or(|revised| row.created_at.as_str() > revised))
677 .max_by(|a, b| a.created_at.cmp(&b.created_at))
678 .map(|row| PersonRequest {
679 author_id: row.author_id,
680 author_name: row.author_name,
681 created_at: row.created_at,
682 }))
683 }
684
685 /// Marks a pull request a g1t agent has just opened as one g1t sees
686 /// through.
687 pub(crate) async fn manage(&self, pull: &Pull) -> Result<()> {
688 if !made_by_g1t(pull) {
689 return Ok(());
690 }
691 self.db
692 .prepare("UPDATE pulls SET managed = 1 WHERE id = ?")
693 .bind(&[pull.id.as_str().into()])?
694 .run()
695 .await?;
696 Ok(())
697 }
698
699 /// Takes a step for a pull request, if nobody else has. One statement,
700 /// so that two callers cannot both take it.
Agents asked while not at work are woken to answer701 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 shell702 let now = now_ms();
703 let revision = if revising {
704 ", revisions = revisions + 1, revised_at = ?1"
705 } else {
706 ""
707 };
708 Ok(self
709 .db
710 .prepare(format!(
711 "UPDATE pulls SET working_on = ?2, working_until = ?3{revision}
712 WHERE id = ?4 AND status = 'open' AND stalled IS NULL
713 AND (working_until IS NULL OR working_until < ?1)
714 RETURNING id AS value"
715 ))
716 .bind(&[
717 rfc3339(now).into(),
718 step.into(),
719 rfc3339(now + minutes * 60 * 1000).into(),
720 pull_id.into(),
721 ])?
722 .first::<ValueRow>(None)
723 .await?
724 .is_some())
725 }
726
727 /// What the author is told when sent back: the checks that failed and
Fast pages, required checks on the branch, self-hosted runners, honest incidents728 /// what their failing jobs printed, why the merge queue took it out, or
729 /// the review and its comments on lines.
Agents as a team: lifecycle, merge queue, billing and a new shell730 async fn feedback(&self, pull: &Pull, feedback: &Feedback) -> Result<String> {
731 match feedback {
732 Feedback::FailedChecks => {
Sidebar: the panels really slide733 let run = self.latest_checks(&pull.id).await?;
734 // Why it failed, when that is more than a list of commands:
735 // the merge queue saying what broke in the combined state.
736 let why = run.as_ref().and_then(|run| run.error.clone()).map(|error| format!("{error}\n\n")).unwrap_or_default();
737 let failed: Vec<String> = run
Agents as a team: lifecycle, merge queue, billing and a new shell738 .map(|run| run.results)
739 .unwrap_or_default()
740 .into_iter()
741 .filter(|result| !result.passed)
742 .map(|result| {
743 let length = result.output.chars().count();
744 let output: String = result
745 .output
746 .chars()
747 .skip(length.saturating_sub(MAX_CHECK_OUTPUT_CHARS))
748 .collect();
749 let exit = result
750 .exit_code
751 .map_or("it was stopped for taking too long".to_owned(), |code| {
752 format!("exit code {code}")
753 });
754 format!("`{}` failed ({exit}):\n\n{}", result.command, output.trim())
755 })
756 .collect();
Sidebar: the panels really slide757 if failed.is_empty() {
758 return Ok(format!(
759 "{why}Find the cause, fix it in your change, and push. Use get_workflow_run and get_job_logs for any workflow named above."
760 ));
761 }
Agents as a team: lifecycle, merge queue, billing and a new shell762 Ok(format!(
Fast pages, required checks on the branch, self-hosted runners, honest incidents763 "{why}These commands failed against your change.\n\n{}",
Agents as a team: lifecycle, merge queue, billing and a new shell764 failed.join("\n\n")
765 ))
766 }
GitHub Actions on g1t, part two: running workflows767 Feedback::FailedWorkflows => {
Fast pages, required checks on the branch, self-hosted runners, honest incidents768 let (statuses, settings) = futures_util::future::try_join(
769 self.statuses(&pull.repo_id, pull.head_commit.as_deref()),
770 self.settings(&pull.repo_id),
771 )
772 .await?;
773 let required = |context: &str| {
774 let name = g1t_contracts::work::check_name(context).0;
775 settings.required_checks.iter().any(|wanted| wanted.eq_ignore_ascii_case(name))
776 };
777 let failing: Vec<&CommitStatus> =
778 statuses.iter().filter(|s| s.state == "failure" || s.state == "error").collect();
779 let failed: Vec<String> = failing
GitHub Actions on g1t, part two: running workflows780 .iter()
781 .map(|s| {
782 let run = s.target_url.as_deref().and_then(|url| url.rsplit('/').next()).unwrap_or_default();
783 format!(
Fast pages, required checks on the branch, self-hosted runners, honest incidents784 "- {}{} ({}): run `{run}`, {}",
GitHub Actions on g1t, part two: running workflows785 s.context,
Fast pages, required checks on the branch, self-hosted runners, honest incidents786 if required(&s.context) { ", required to merge" } else { "" },
Free while g1t is being built out; agents can check out their own forks787 s.description.as_deref().unwrap_or("failed"),
788 s.target_url.as_deref().unwrap_or_default()
GitHub Actions on g1t, part two: running workflows789 )
790 })
791 .collect();
Fast pages, required checks on the branch, self-hosted runners, honest incidents792 let logs = self.failing_logs(pull, &failing).await.unwrap_or_default();
Free while g1t is being built out; agents can check out their own forks793 // Named outright: the agent cannot guess it from its fork.
794 let repo = g1t_kit::call::<_, Option<RepoPath>>(
795 &self.repos,
796 "path_by_id",
797 &g1t_contracts::repos::PathByIdArgs { id: pull.repo_id.clone() },
798 )
799 .await?
800 .map(|path| format!("{}/{}", path.namespace, path.name))
801 .unwrap_or_default();
Fast pages, required checks on the branch, self-hosted runners, honest incidents802 let logs = if logs.is_empty() {
803 String::new()
804 } else {
805 format!("\n\nThe end of what the failing jobs printed:\n\n{}", logs.join("\n\n"))
806 };
GitHub Actions on g1t, part two: running workflows807 Ok(format!(
Fast pages, required checks on the branch, self-hosted runners, honest incidents808 "These checks failed on your latest commit to {repo}. They are the repository's workflows, run on your pull request:\n\n{}{logs}\n\n\
809 For more, use the `get_workflow_run` tool (repo `{repo}` and the run's id), \
GitHub Actions on g1t, part two: running workflows810 then `get_job_logs` for the job that failed. Fix the cause in the code, not the workflow, \
Fast pages, required checks on the branch, self-hosted runners, honest incidents811 unless the workflow itself is wrong. Push, and the checks run again.",
GitHub Actions on g1t, part two: running workflows812 failed.join("\n")
813 ))
814 }
Agents as a team: lifecycle, merge queue, billing and a new shell815 Feedback::Review(finished_at) => {
816 // Everything a review says is recorded at the moment it finished.
817 let notes = self
818 .db
819 .prepare(
820 "SELECT body, path, line FROM comments
821 WHERE repo_id = ? AND number = ? AND author_id = ? AND created_at = ?
822 ORDER BY id",
823 )
824 .bind(&[
825 pull.repo_id.as_str().into(),
826 pull.number.into(),
827 AGENT_ID.into(),
828 finished_at.as_str().into(),
829 ])?
830 .all()
831 .await?
832 .results::<ReviewNote>()?;
833 let mut on_lines = Vec::new();
834 let mut summary = String::new();
835 for note in notes {
836 match (note.path, note.line) {
837 (Some(path), Some(line)) => {
838 on_lines.push(format!("- `{path}` line {line}: {}", note.body));
839 }
840 (Some(path), None) => on_lines.push(format!("- `{path}`: {}", note.body)),
841 (None, _) => summary = note.body,
842 }
843 }
844 let mut text = format!(
845 "Another agent reviewed your change and asked for changes.\n\n{summary}"
846 );
847 if !on_lines.is_empty() {
848 text.push_str("\n\nIts comments on lines:\n");
849 text.push_str(&on_lines.join("\n"));
850 }
851 Ok(text)
852 }
853 Feedback::Person(request) => {
854 // What they wrote since the agent last revised, which their
855 // request for changes closes.
856 let revised: Option<String> = self
857 .db
858 .prepare("SELECT revised_at AS value FROM pulls WHERE id = ?")
859 .bind(&[pull.id.as_str().into()])?
860 .first::<Option<String>>(Some("value"))
861 .await?
862 .flatten();
863 let notes = self
864 .db
865 .prepare(
866 "SELECT body, path, line FROM comments
867 WHERE repo_id = ? AND number = ? AND author_id = ?
868 AND created_at > ? AND created_at <= ?
869 ORDER BY id",
870 )
871 .bind(&[
872 pull.repo_id.as_str().into(),
873 pull.number.into(),
874 request.author_id.as_str().into(),
875 revised.unwrap_or_default().into(),
876 request.created_at.as_str().into(),
877 ])?
878 .all()
879 .await?
880 .results::<ReviewNote>()?;
881 let mut on_lines = Vec::new();
882 let mut said = Vec::new();
883 for note in notes {
884 match (note.path, note.line) {
885 (Some(path), Some(line)) => {
886 on_lines.push(format!("- `{path}` line {line}: {}", note.body));
887 }
888 (Some(path), None) => on_lines.push(format!("- `{path}`: {}", note.body)),
889 (None, _) => said.push(note.body),
890 }
891 }
892 let mut text = format!(
893 "{} reviewed your change and asked for changes.\n\n{}",
894 request.author_name,
895 said.join("\n\n")
896 );
897 if !on_lines.is_empty() {
898 text.push_str("\n\nTheir comments on lines:\n");
899 text.push_str(&on_lines.join("\n"));
900 }
901 Ok(text)
902 }
903 }
904 }
905
Fast pages, required checks on the branch, self-hosted runners, honest incidents906 /// The end of what the failed jobs of failing workflow runs printed, a
907 /// few jobs at most, for an agent sent back to fix them. Empty where the
908 /// runs or their logs cannot be read.
909 async fn failing_logs(&self, pull: &Pull, failing: &[&CommitStatus]) -> Result<Vec<String>> {
910 use g1t_contracts::actions::{JobLog, LogsArgs, RunArgs, RunDetail};
911 let Some(repo) = g1t_kit::call::<_, Option<RepoPath>>(
912 &self.repos,
913 "path_by_id",
914 &g1t_contracts::repos::PathByIdArgs { id: pull.repo_id.clone() },
915 )
916 .await?
917 else {
918 return Ok(Vec::new());
919 };
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights920 let viewer = self.owner_viewer(pull).await?;
Fast pages, required checks on the branch, self-hosted runners, honest incidents921 let mut out = Vec::new();
922 for status in failing {
923 let Some(run_id) = status
924 .target_url
925 .as_deref()
926 .and_then(|url| url.split("/actions/runs/").nth(1))
927 .map(|rest| rest.split(['/', '?', '#']).next().unwrap_or_default().to_owned())
928 .filter(|id| !id.is_empty())
929 else {
930 continue;
931 };
932 let detail: Outcome<RunDetail> = g1t_kit::call(
933 &self.actions,
934 "run",
935 &RunArgs { repo: repo.clone(), viewer: viewer.clone(), id: run_id },
936 )
937 .await?;
938 let Outcome::Ok(detail) = detail else { continue };
939 let failed_jobs = detail.jobs.into_iter().filter(|job| {
940 job.status == "completed" && matches!(job.conclusion.as_deref(), Some("failure" | "timed_out"))
941 });
942 for job in failed_jobs {
943 if out.len() >= MAX_FAILED_JOBS {
944 return Ok(out);
945 }
946 let mut text = String::new();
947 let mut after = 0;
948 for _ in 0..MAX_LOG_PAGES {
949 let page: Outcome<JobLog> = g1t_kit::call(
950 &self.actions,
951 "logs",
952 &LogsArgs { repo: repo.clone(), viewer: viewer.clone(), job: job.id.clone(), after },
953 )
954 .await?;
955 let Outcome::Ok(page) = page else { break };
956 let Some(last) = page.chunks.last().map(|chunk| chunk.seq) else { break };
957 for chunk in &page.chunks {
958 text.push_str(&chunk.text);
959 if !chunk.text.ends_with('\n') {
960 text.push('\n');
961 }
962 }
963 // Only the end is kept, so the start can go as it is read.
964 let length = text.chars().count();
965 if length > MAX_JOB_LOG_CHARS * 2 {
966 text = text.chars().skip(length - MAX_JOB_LOG_CHARS).collect();
967 }
968 after = last;
969 if page.chunks.len() < 500 {
970 break;
971 }
972 }
973 let length = text.chars().count();
974 let tail: String = text.chars().skip(length.saturating_sub(MAX_JOB_LOG_CHARS)).collect();
975 if !tail.trim().is_empty() {
976 out.push(format!("{} / {}:\n```\n{}\n```", status.context, job.name, tail.trim_end()));
977 }
978 }
979 }
980 Ok(out)
981 }
982
Agents as a team: lifecycle, merge queue, billing and a new shell983 pub(crate) async fn advance(&self, a: AdvanceArgs) -> Result<Advance> {
984 let Some(pull) = self.pull_by_id(&a.pull_id).await? else {
985 return Ok(Advance::None);
986 };
987 if pull.status != PullStatus::Open {
988 return Ok(Advance::None);
989 }
Agents move along on private repositories too990 // As a member: a private repository would look missing otherwise,
991 // and the pull request would never move.
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights992 let viewer: Viewer = self.owner_viewer(&pull).await?;
Agents as a team: lifecycle, merge queue, billing and a new shell993 let repo: Outcome<Repo> = g1t_kit::call(
994 &self.repos,
995 "get_by_id",
996 &GetByIdArgs {
997 id: pull.repo_id.clone(),
998 viewer,
999 },
1000 )
1001 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1002 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 shell1003 return Ok(Advance::None);
1004 };
1005 let issue = match pull.issue {
1006 Some(number) => self.issue(&pull.repo_id, number).await?,
1007 None => None,
1008 };
1009 let behind = self.is_behind(&repo.id, &pull).await?;
1010 let Some((lifecycle, next)) = self.assess(&pull, &issue, behind).await? else {
1011 return Ok(Advance::None);
1012 };
1013
1014 if matches!(next, Next::Merge) {
1015 self.merge_by_policy(&repo, &pull).await?;
1016 return Ok(Advance::None);
1017 }
1018 let (step, minutes) = match &next {
1019 Next::Wait | Next::Merge => return Ok(Advance::None),
1020 Next::Review => ("review", REVIEW_MINUTES),
1021 Next::Revise(_) => ("revision", REVISION_MINUTES),
1022 Next::CatchUp => ("catch_up", CATCH_UP_MINUTES),
1023 };
1024 let feedback = match &next {
1025 Next::Revise(feedback) => self.feedback(&pull, feedback).await?,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1026 Next::CatchUp => self.conflict_note(&pull, &repo.default_branch).await?,
Agents as a team: lifecycle, merge queue, billing and a new shell1027 _ => String::new(),
1028 };
1029 if !self
1030 .claim(&pull.id, step, minutes, matches!(next, Next::Revise(_)))
1031 .await?
1032 {
1033 return Ok(Advance::None);
1034 }
1035 // Said in the conversation, so nobody has to wonder why a review or
1036 // a new commit appeared.
1037 let told = match &next {
1038 Next::Review => {
1039 self.db
1040 .prepare(
1041 "UPDATE pulls SET reviewers = json_insert(reviewers, '$[#]', ?1)
1042 WHERE id = ?2 AND NOT EXISTS (
1043 SELECT 1 FROM json_each(pulls.reviewers) WHERE json_each.value = ?1)",
1044 )
1045 .bind(&[AGENT_NAME.into(), pull.id.as_str().into()])?
1046 .run()
1047 .await?;
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1048 "requested a review from g1t".to_owned()
Agents as a team: lifecycle, merge queue, billing and a new shell1049 }
1050 Next::Revise(Feedback::FailedChecks) => {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1051 "sent g1t back to fix what failed in the merge queue".to_owned()
Agents as a team: lifecycle, merge queue, billing and a new shell1052 }
GitHub Actions on g1t, part two: running workflows1053 Next::Revise(Feedback::FailedWorkflows) => {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1054 "sent g1t back to fix the failed checks".to_owned()
GitHub Actions on g1t, part two: running workflows1055 }
Agents as a team: lifecycle, merge queue, billing and a new shell1056 Next::Revise(Feedback::Review(_)) => {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1057 "sent g1t back to address the review".to_owned()
Agents as a team: lifecycle, merge queue, billing and a new shell1058 }
1059 _ => format!(
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1060 "asked g1t to bring this up to date with {}",
Agents as a team: lifecycle, merge queue, billing and a new shell1061 repo.default_branch
1062 ),
1063 };
1064 self.note(
1065 &pull.repo_id,
1066 pull.number,
1067 (POLICY_ACTOR_ID, POLICY_ACTOR_NAME),
1068 &told,
1069 )
1070 .await?;
1071 let job = LifecycleJob {
1072 pull_id: pull.id,
1073 repo: RepoPath {
1074 namespace: repo.namespace,
1075 name: repo.name,
1076 },
1077 number: pull.number,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1078 author: pull.requested_by.unwrap_or(pull.author),
Agents as a team: lifecycle, merge queue, billing and a new shell1079 source,
1080 branch: None,
1081 default_branch: repo.default_branch,
1082 title: pull.title,
1083 description: pull.body.unwrap_or_default(),
1084 issue,
1085 feedback,
1086 round: lifecycle.revisions + 1,
1087 };
1088 Ok(match next {
1089 Next::Review => Advance::Review { job },
1090 Next::Revise(_) => Advance::Revise { job },
1091 Next::CatchUp => Advance::CatchUp { job },
1092 Next::Wait | Next::Merge => Advance::None,
1093 })
1094 }
1095
1096 /// Lands a pull request that is ready, on the authority of the
1097 /// repository's settings instead of a person's click.
1098 async fn merge_by_policy(&self, repo: &Repo, pull: &Pull) -> Result<()> {
1099 if !self.claim(&pull.id, "merge", MERGE_MINUTES, false).await? {
1100 return Ok(());
1101 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1102 // g1t acts for the workspace whose members turned this on, with the
1103 // default base permission (Write), which merging needs.
Agents as a team: lifecycle, merge queue, billing and a new shell1104 let actor = User {
1105 id: POLICY_ACTOR_ID.to_owned(),
1106 username: POLICY_ACTOR_NAME.to_owned(),
1107 verified: true,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1108 workspaces: vec![Membership::member(repo.namespace.to_lowercase())],
Agents as a team: lifecycle, merge queue, billing and a new shell1109 ..User::default()
1110 };
1111 let merged = self
1112 .merge_pull(PullActionArgs {
1113 actor,
1114 repo: RepoPath {
1115 namespace: repo.namespace.clone(),
1116 name: repo.name.clone(),
1117 },
1118 number: pull.number,
1119 summary: String::new(),
1120 keep_issue_open: false,
1121 ignore_checks: false,
1122 })
1123 .await?;
1124 match merged {
1125 Outcome::Ok(_) => Ok(()),
1126 // Most likely the branch moved in the moment between: let go, and
1127 // the next look at it will catch up and try again.
1128 Outcome::Fail(failure) if failure.code == FailureCode::Conflict => {
1129 self.db
1130 .prepare(
1131 "UPDATE pulls SET working_on = NULL, working_until = NULL
1132 WHERE id = ? AND working_on = 'merge'",
1133 )
1134 .bind(&[pull.id.as_str().into()])?
1135 .run()
1136 .await?;
1137 Ok(())
1138 }
1139 Outcome::Fail(failure) => {
1140 self.stall(StallArgs {
1141 pull_id: pull.id.clone(),
1142 reason: format!("g1t could not merge this: {}", failure.message),
1143 })
1144 .await?;
1145 Ok(())
1146 }
1147 }
1148 }
1149
1150 /// Records that a merge was asked for while the pull request was
1151 /// behind, and announces it so that the runner brings it up to date.
1152 pub(crate) async fn request_landing(
1153 &self,
1154 pull: &Pull,
1155 actor: &User,
1156 keep_issue_open: bool,
1157 ) -> Result<()> {
1158 let now = now_ms();
1159 let request = serde_json::to_string(&LandRequest {
1160 actor: actor.clone(),
1161 keep_issue_open,
1162 })?;
1163 self.db
1164 .prepare(
1165 "UPDATE pulls
1166 SET land_requested = ?, land_requested_at = ?, stalled = NULL,
1167 working_on = 'catch_up', working_until = ?
1168 WHERE id = ?",
1169 )
1170 .bind(&[
1171 request.into(),
1172 rfc3339(now).into(),
1173 rfc3339(now + CATCH_UP_MINUTES * 60 * 1000).into(),
1174 pull.id.as_str().into(),
1175 ])?
1176 .run()
1177 .await?;
1178 self.publish(
1179 "pull.merge_requested",
1180 &pull.repo_id,
1181 actor,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1182 Self::pull_event(pull),
Agents as a team: lifecycle, merge queue, billing and a new shell1183 )
1184 .await
1185 }
1186
1187 /// The merge waiting on a pull request, if one was asked for recently
1188 /// enough to still stand.
1189 async fn land_request(&self, pull_id: &str) -> Result<Option<LandRequest>> {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1190 let row = self.land_row(pull_id).await?;
Agents as a team: lifecycle, merge queue, billing and a new shell1191 let oldest = rfc3339(now_ms().saturating_sub(CATCH_UP_MINUTES * 60 * 1000));
1192 Ok(row
1193 .filter(|row| {
1194 row.land_requested_at
1195 .as_deref()
1196 .is_some_and(|at| at >= oldest.as_str())
1197 })
1198 .and_then(|row| row.land_requested)
1199 .and_then(|request| serde_json::from_str(&request).ok()))
1200 }
1201
1202 /// Whether a merge is waiting on a pull request, and why g1t stopped
1203 /// working on it if it did.
1204 pub(crate) async fn landing_state(&self, pull_id: &str) -> Result<(bool, Option<String>)> {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1205 let stalled = self.land_row(pull_id).await?.and_then(|row| row.stalled);
1206 Ok((self.land_request(pull_id).await?.is_some(), stalled))
1207 }
1208
1209 /// The merge waiting on a pull request and why g1t stopped, as read
1210 /// for this request (prefetch.rs) or now.
1211 async fn land_row(&self, pull_id: &str) -> Result<Option<LandRow>> {
1212 if let Some(found) = self.prefetched_pull(pull_id) {
1213 return found.first::<LandRow>(Slot::Pull);
1214 }
1215 self.db
Agents as a team: lifecycle, merge queue, billing and a new shell1216 .prepare("SELECT land_requested, land_requested_at, stalled FROM pulls WHERE id = ?")
1217 .bind(&[pull_id.into()])?
1218 .first::<LandRow>(None)
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1219 .await
Agents as a team: lifecycle, merge queue, billing and a new shell1220 }
1221
1222 async fn forget_landing(&self, pull_id: &str) -> Result<()> {
1223 self.db
1224 .prepare(
1225 "UPDATE pulls SET land_requested = NULL, land_requested_at = NULL WHERE id = ?",
1226 )
1227 .bind(&[pull_id.into()])?
1228 .run()
1229 .await?;
1230 Ok(())
1231 }
1232
1233 /// Lands a pull request whose head has just moved, if a merge of it was
1234 /// waiting for exactly that. What it was caught up to was already
1235 /// checked and reviewed apart from the merge, so the checks are not
1236 /// waited for again; a repository that wants them rerun turns on
1237 /// "require up to date", and then nothing is landed this way.
1238 pub(crate) async fn land_if_requested(&self, pull_id: &str) -> Result<()> {
1239 let Some(request) = self.land_request(pull_id).await? else {
1240 return Ok(());
1241 };
1242 self.forget_landing(pull_id).await?;
1243 let Some(pull) = self.pull_by_id(pull_id).await? else {
1244 return Ok(());
1245 };
1246 let repo: Outcome<Repo> = g1t_kit::call(
1247 &self.repos,
1248 "get_by_id",
1249 &GetByIdArgs {
1250 id: pull.repo_id.clone(),
1251 viewer: Some(request.actor.clone()),
1252 },
1253 )
1254 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1255 let Outcome::Ok(repo) = crate::retired::unless_archived(repo) else {
Agents as a team: lifecycle, merge queue, billing and a new shell1256 return Ok(());
1257 };
1258 let merged = self
1259 .merge_pull(PullActionArgs {
1260 actor: request.actor,
1261 repo: RepoPath {
1262 namespace: repo.namespace,
1263 name: repo.name,
1264 },
1265 number: pull.number,
1266 summary: String::new(),
1267 keep_issue_open: request.keep_issue_open,
1268 ignore_checks: true,
1269 })
1270 .await?;
1271 if let Outcome::Fail(failure) = merged {
1272 self.stall(StallArgs {
1273 pull_id: pull.id,
1274 reason: format!(
1275 "It was brought up to date but could not be merged: {}",
1276 failure.message
1277 ),
1278 })
1279 .await?;
1280 }
1281 Ok(())
1282 }
1283
1284 /// What the runner needs to bring a pull request up to date for a merge
1285 /// that is waiting on it.
1286 pub(crate) async fn catch_up_job(&self, a: CatchUpJobArgs) -> Result<Option<LifecycleJob>> {
1287 if self.land_request(&a.pull_id).await?.is_none() {
1288 return Ok(None);
1289 }
1290 let Some(pull) = self.pull_by_id(&a.pull_id).await? else {
1291 return Ok(None);
1292 };
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1293 // Its owner (whoever asked g1t for it, or its author) can read both
1294 // the repository and the pull request's source.
Agents as a team: lifecycle, merge queue, billing and a new shell1295 let repo: Outcome<Repo> = g1t_kit::call(
1296 &self.repos,
1297 "get_by_id",
1298 &GetByIdArgs {
1299 id: pull.repo_id.clone(),
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1300 viewer: self.owner_viewer(&pull).await?,
Agents as a team: lifecycle, merge queue, billing and a new shell1301 },
1302 )
1303 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1304 let Outcome::Ok(repo) = crate::retired::unless_archived(repo) else {
Agents as a team: lifecycle, merge queue, billing and a new shell1305 return Ok(None);
1306 };
1307 let path = RepoPath {
1308 namespace: repo.namespace,
1309 name: repo.name,
1310 };
1311 let issue = match pull.issue {
1312 Some(number) => self.issue(&pull.repo_id, number).await?,
1313 None => None,
1314 };
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1315 let feedback = self.conflict_note(&pull, &repo.default_branch).await?;
Agents as a team: lifecycle, merge queue, billing and a new shell1316 Ok(Some(LifecycleJob {
1317 pull_id: pull.id,
1318 source: pull.fork.unwrap_or_else(|| path.clone()),
1319 repo: path,
1320 number: pull.number,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1321 author: pull.requested_by.unwrap_or(pull.author),
Agents as a team: lifecycle, merge queue, billing and a new shell1322 branch: pull.branch,
1323 default_branch: repo.default_branch,
1324 title: pull.title,
1325 description: pull.body.unwrap_or_default(),
1326 issue,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1327 feedback,
Agents as a team: lifecycle, merge queue, billing and a new shell1328 round: 0,
1329 }))
1330 }
1331
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1332 /// For an agent catching up: the files g1t already knows conflict, so
1333 /// it reads them first. Empty when none are known.
1334 pub(crate) async fn conflict_note(&self, pull: &Pull, default_branch: &str) -> Result<String> {
1335 Ok(match self.conflicting_files(pull).await? {
1336 Some(files) if !files.is_empty() => format!(
1337 "g1t found ahead of time that merging {default_branch} into this pull request conflicts in these files: {}.",
1338 files.join(", ")
1339 ),
1340 _ => String::new(),
1341 })
1342 }
1343
Agents as a team: lifecycle, merge queue, billing and a new shell1344 pub(crate) async fn stall(&self, a: StallArgs) -> Result<bool> {
1345 self.db
1346 .prepare(
1347 "UPDATE pulls
1348 SET stalled = ?, working_on = NULL, working_until = NULL,
1349 land_requested = NULL, land_requested_at = NULL
1350 WHERE id = ? AND status = 'open'",
1351 )
1352 .bind(&[a.reason.trim().into(), a.pull_id.as_str().into()])?
1353 .run()
1354 .await?;
1355 self.db
1356 .prepare("UPDATE pulls SET stage = 'needs_you', stage_detail = ? WHERE id = ?")
1357 .bind(&[a.reason.trim().into(), a.pull_id.into()])?
1358 .run()
1359 .await?;
1360 Ok(true)
1361 }
1362
1363 pub(crate) async fn managed_pulls(&self, a: ManagedPullsArgs) -> Result<Vec<String>> {
1364 let rows = self
1365 .db
1366 .prepare(
1367 "SELECT id AS value FROM pulls
1368 WHERE status = 'open' AND managed = 1 AND stalled IS NULL
1369 AND (?1 IS NULL OR repo_id = ?1)
1370 ORDER BY updated_at DESC LIMIT ?2",
1371 )
1372 .bind(&[
1373 a.repo_id.map_or(JsValue::NULL, JsValue::from),
1374 MANAGED_PAGE.into(),
1375 ])?
1376 .all()
1377 .await?
1378 .results::<ValueRow>()?;
1379 Ok(rows.into_iter().map(|row| row.value).collect())
1380 }
1381}
1382
1383#[cfg(test)]
1384mod tests {
1385 use super::*;
1386
1387 const MAX_REVISIONS: u32 = 2;
1388
1389 #[test]
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1390 fn a_verdict_from_whoever_asked_for_g1t_s_change_is_not_someone_else_s() {
1391 use crate::rows::stored::{ASKER, G1T, pull};
1392 let made = pull(G1T, Some(ASKER));
1393 assert!(made_by_g1t(&made));
1394 // The person it was made for is held to what an author was: their
1395 // request for changes does not send g1t back as a reviewer's
1396 // would, and their approval does not lift a hold.
1397 assert!(!from_someone_else(&made, ASKER.0));
1398 assert!(!from_someone_else(&made, AGENT_ID));
1399 assert!(from_someone_else(&made, "usr_reviewer"));
1400 // Anyone's own pull request, the same.
1401 let own = pull(ASKER, None);
1402 assert!(!from_someone_else(&own, ASKER.0));
1403 assert!(from_someone_else(&own, "usr_reviewer"));
1404 }
1405
1406 #[test]
Agents as a team: lifecycle, merge queue, billing and a new shell1407 fn approvals_the_repository_wants_are_waited_for() {
1408 let short = || Facts {
1409 review: reviewed(Some(Verdict::Approve)),
1410 approvals_missing: Some("This repository requires 1 approving review.".to_owned()),
1411 ..facts()
1412 };
1413 let (lifecycle, next) = decide(short());
1414 assert_eq!(lifecycle.stage, Stage::NeedsYou);
1415 assert_eq!(
1416 lifecycle.detail,
1417 "This repository requires 1 approving review."
1418 );
1419 assert!(matches!(next, Next::Wait));
1420 // Not even a repository that merges by itself merges without them.
1421 let automatic = Facts {
1422 auto_merge: true,
1423 ..short()
1424 };
1425 assert_eq!(outcome(automatic), (Stage::NeedsYou, "wait"));
1426 }
1427
1428 #[test]
1429 fn a_repository_can_leave_review_to_people() {
1430 let unreviewed = Facts {
1431 agent_review: false,
1432 ..facts()
1433 };
1434 assert_eq!(outcome(unreviewed), (Stage::Ready, "wait"));
1435 let failing = Facts {
1436 agent_review: false,
1437 check_status: Some(CheckStatus::Failed),
1438 ..facts()
1439 };
1440 assert_eq!(outcome(failing), (Stage::Revising, "revise for checks"));
1441 }
1442
1443 #[test]
1444 fn a_repository_sets_how_often_the_author_is_sent_back() {
1445 let never = Facts {
1446 max_revisions: 0,
1447 check_status: Some(CheckStatus::Failed),
1448 ..facts()
1449 };
1450 assert_eq!(outcome(never), (Stage::NeedsYou, "wait"));
1451 }
1452
Fast pages, required checks on the branch, self-hosted runners, honest incidents1453 /// Checks on a head commit: `(context, state)` statuses, against the
1454 /// required check names.
1455 fn checks(statuses: &[(&str, &str)], required: &[&str]) -> WorkflowFacts {
1456 let statuses: Vec<CommitStatus> = statuses
1457 .iter()
1458 .map(|(context, state)| CommitStatus {
1459 context: (*context).to_owned(),
1460 state: (*state).to_owned(),
1461 description: None,
1462 target_url: None,
1463 updated_at: String::new(),
1464 })
1465 .collect();
1466 let required: Vec<String> = required.iter().map(|name| (*name).to_owned()).collect();
1467 WorkflowFacts::of(&statuses, &required)
1468 }
1469
1470 /// A pull request that is ready for review, whose required check
1471 /// passed, and nothing else yet.
Agents as a team: lifecycle, merge queue, billing and a new shell1472 fn facts() -> Facts {
1473 Facts {
1474 draft: false,
1475 stalled: None,
1476 working_on: None,
Fast pages, required checks on the branch, self-hosted runners, honest incidents1477 check_status: None,
Agents as a team: lifecycle, merge queue, billing and a new shell1478 review_pending: false,
1479 revisions: 0,
1480 review: None,
1481 behind: false,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1482 conflicting: false,
Agents as a team: lifecycle, merge queue, billing and a new shell1483 auto_merge: false,
1484 require_up_to_date: false,
1485 agent_review: true,
1486 max_revisions: MAX_REVISIONS,
1487 approvals_missing: None,
1488 person_request: None,
1489 queued: None,
Fast pages, required checks on the branch, self-hosted runners, honest incidents1490 workflows: checks(&[("CI / pull_request", "success")], &["CI"]),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1491 low_confidence: None,
Agents as a team: lifecycle, merge queue, billing and a new shell1492 }
1493 }
1494
1495 #[test]
Fast pages, required checks on the branch, self-hosted runners, honest incidents1496 fn failed_checks_send_the_agent_back_and_running_ones_wait() {
GitHub Actions on g1t, part two: running workflows1497 let failed = Facts {
Fast pages, required checks on the branch, self-hosted runners, honest incidents1498 workflows: checks(&[("CI / pull_request", "failure")], &["CI"]),
GitHub Actions on g1t, part two: running workflows1499 ..facts()
1500 };
1501 let (lifecycle, next) = decide(failed);
1502 assert!(matches!(next, Next::Revise(Feedback::FailedWorkflows)));
1503 assert!(lifecycle.detail.contains("CI / pull_request failed"));
1504 let running = Facts {
Fast pages, required checks on the branch, self-hosted runners, honest incidents1505 workflows: checks(&[("CI / pull_request", "pending")], &["CI"]),
GitHub Actions on g1t, part two: running workflows1506 ..facts()
1507 };
1508 let (lifecycle, next) = decide(running);
1509 assert!(matches!(next, Next::Wait));
1510 assert!(lifecycle.detail.contains("Waiting for CI / pull_request"));
1511 }
1512
1513 #[test]
Fast pages, required checks on the branch, self-hosted runners, honest incidents1514 fn a_check_the_branch_does_not_require_is_fixed_but_does_not_hold_it_for_ever() {
1515 // Lint is not required: the agent is still sent back to fix it...
1516 let lint = checks(&[("CI / pull_request", "success"), ("Lint / pull_request", "failure")], &["CI"]);
1517 let failing = Facts { workflows: lint.clone(), ..facts() };
1518 assert_eq!(outcome(failing), (Stage::Revising, "revise for workflows"));
1519 // ...but once it is out of revisions, Lint no longer holds it.
1520 let exhausted = Facts {
1521 workflows: lint,
1522 revisions: MAX_REVISIONS,
1523 review: reviewed(Some(Verdict::Approve)),
1524 ..facts()
1525 };
1526 assert_eq!(outcome(exhausted), (Stage::Ready, "wait"));
1527 // A required check that still fails asks a person.
1528 let required = Facts {
1529 workflows: checks(&[("CI / pull_request", "failure")], &["CI"]),
1530 revisions: MAX_REVISIONS,
1531 ..facts()
1532 };
1533 let (lifecycle, next) = decide(required);
1534 assert_eq!(lifecycle.stage, Stage::NeedsYou);
1535 assert_eq!(lifecycle.detail, "The required check CI still fails after the agent revised twice.");
1536 assert!(matches!(next, Next::Wait));
1537 }
1538
1539 #[test]
1540 fn a_required_check_that_has_not_reported_is_waited_for() {
1541 let missing = Facts {
1542 workflows: checks(&[("CI / pull_request", "success")], &["CI", "Deploy"]),
1543 review: reviewed(Some(Verdict::Approve)),
1544 auto_merge: true,
1545 ..facts()
1546 };
1547 let (lifecycle, next) = decide(missing);
1548 assert_eq!(lifecycle.stage, Stage::Checking);
1549 assert_eq!(lifecycle.detail, "Waiting for the required check Deploy to report on its latest commit.");
1550 assert!(matches!(next, Next::Wait), "auto-merge must not land it");
1551 }
1552
1553 #[test]
Agents as a team: lifecycle, merge queue, billing and a new shell1554 fn a_queued_pull_request_waits_in_the_queue() {
1555 let queued = Facts {
1556 review: reviewed(Some(Verdict::Approve)),
1557 queued: Some((QueueState::Testing, vec![12, 14])),
1558 auto_merge: true,
1559 ..facts()
1560 };
1561 let (lifecycle, next) = decide(queued);
1562 assert_eq!(lifecycle.stage, Stage::Queued);
1563 assert!(lifecycle.detail.contains("#12, #14"));
1564 assert!(matches!(next, Next::Wait));
1565 }
1566
1567 fn asked_by_a_person() -> Option<PersonRequest> {
1568 Some(PersonRequest {
1569 author_id: "usr_reviewer".to_owned(),
1570 author_name: "g1t-reviewer".to_owned(),
1571 created_at: "2026-10-02T11:00:00.000Z".to_owned(),
1572 })
1573 }
1574
1575 #[test]
1576 fn a_person_asking_for_changes_sends_the_agent_back() {
1577 let asked = Facts {
1578 review: reviewed(Some(Verdict::Approve)),
1579 approvals_missing: Some("A reviewer has asked for changes.".to_owned()),
1580 person_request: asked_by_a_person(),
1581 ..facts()
1582 };
1583 let (lifecycle, _) = decide(Facts {
1584 person_request: asked_by_a_person(),
1585 ..facts()
1586 });
1587 assert!(lifecycle.detail.starts_with("g1t-reviewer asked for changes"));
1588 assert_eq!(outcome(asked), (Stage::Revising, "revise for a person"));
1589 }
1590
1591 #[test]
1592 fn a_person_is_asked_once_the_revisions_run_out() {
1593 let exhausted = Facts {
1594 person_request: asked_by_a_person(),
1595 revisions: MAX_REVISIONS,
1596 ..facts()
1597 };
1598 assert_eq!(outcome(exhausted), (Stage::NeedsYou, "wait"));
1599 }
1600
1601 fn reviewed(verdict: Option<Verdict>) -> Option<FinishedReview> {
1602 Some(FinishedReview {
1603 finished_at: "2026-10-02T10:00:00.000Z".to_owned(),
1604 verdict,
1605 })
1606 }
1607
1608 /// The stage, and a word for the step to take.
1609 fn outcome(facts: Facts) -> (Stage, &'static str) {
1610 let (lifecycle, next) = decide(facts);
1611 let step = match next {
1612 Next::Wait => "wait",
1613 Next::Review => "review",
1614 Next::Revise(Feedback::FailedChecks) => "revise for checks",
GitHub Actions on g1t, part two: running workflows1615 Next::Revise(Feedback::FailedWorkflows) => "revise for workflows",
Agents as a team: lifecycle, merge queue, billing and a new shell1616 Next::Revise(Feedback::Review(_)) => "revise for review",
1617 Next::Revise(Feedback::Person(_)) => "revise for a person",
1618 Next::CatchUp => "catch up",
1619 Next::Merge => "merge",
1620 };
1621 (lifecycle.stage, step)
1622 }
1623
1624 #[test]
1625 fn nothing_is_started_while_the_agent_is_still_working() {
1626 let draft = Facts {
1627 draft: true,
1628 check_status: None,
1629 ..facts()
1630 };
1631 assert_eq!(outcome(draft), (Stage::Working, "wait"));
1632 }
1633
1634 #[test]
1635 fn checks_come_before_review() {
1636 let unchecked = Facts {
Fast pages, required checks on the branch, self-hosted runners, honest incidents1637 workflows: checks(&[], &["CI"]),
Agents as a team: lifecycle, merge queue, billing and a new shell1638 ..facts()
1639 };
1640 assert_eq!(outcome(unchecked), (Stage::Checking, "wait"));
1641 let running = Facts {
Fast pages, required checks on the branch, self-hosted runners, honest incidents1642 workflows: checks(&[("CI / pull_request", "pending")], &["CI"]),
Agents as a team: lifecycle, merge queue, billing and a new shell1643 ..facts()
1644 };
1645 assert_eq!(outcome(running), (Stage::Checking, "wait"));
1646 assert_eq!(outcome(facts()), (Stage::Reviewing, "review"));
1647 }
1648
1649 #[test]
Fast pages, required checks on the branch, self-hosted runners, honest incidents1650 fn a_branch_that_requires_no_checks_goes_straight_to_review() {
Agents as a team: lifecycle, merge queue, billing and a new shell1651 let unchecked = Facts {
Fast pages, required checks on the branch, self-hosted runners, honest incidents1652 workflows: WorkflowFacts::default(),
Agents as a team: lifecycle, merge queue, billing and a new shell1653 ..facts()
1654 };
1655 assert_eq!(outcome(unchecked), (Stage::Reviewing, "review"));
1656 }
1657
1658 #[test]
Fast pages, required checks on the branch, self-hosted runners, honest incidents1659 fn failing_in_the_merge_queue_sends_the_author_back() {
Agents as a team: lifecycle, merge queue, billing and a new shell1660 let failed = Facts {
1661 check_status: Some(CheckStatus::Failed),
1662 ..facts()
1663 };
Fast pages, required checks on the branch, self-hosted runners, honest incidents1664 let (lifecycle, next) = decide(failed);
1665 assert_eq!(lifecycle.detail, "It failed in the merge queue. The agent is being sent back to fix it.");
1666 assert!(matches!(next, Next::Revise(Feedback::FailedChecks)));
Agents as a team: lifecycle, merge queue, billing and a new shell1667 }
1668
1669 #[test]
1670 fn checks_that_could_not_run_are_a_persons_problem() {
1671 let errored = Facts {
1672 check_status: Some(CheckStatus::Errored),
1673 ..facts()
1674 };
1675 assert_eq!(outcome(errored), (Stage::NeedsYou, "wait"));
1676 }
1677
1678 #[test]
1679 fn a_review_asking_for_changes_sends_the_author_back() {
1680 let changes = Facts {
1681 review: reviewed(Some(Verdict::RequestChanges)),
1682 ..facts()
1683 };
1684 assert_eq!(outcome(changes), (Stage::Revising, "revise for review"));
1685 }
1686
1687 #[test]
1688 fn the_author_is_sent_back_only_so_many_times() {
1689 let failing = Facts {
1690 check_status: Some(CheckStatus::Failed),
1691 revisions: MAX_REVISIONS,
1692 ..facts()
1693 };
1694 assert_eq!(outcome(failing), (Stage::NeedsYou, "wait"));
1695 let unconvinced = Facts {
1696 review: reviewed(Some(Verdict::RequestChanges)),
1697 revisions: MAX_REVISIONS,
1698 ..facts()
1699 };
1700 assert_eq!(outcome(unconvinced), (Stage::NeedsYou, "wait"));
1701 // One short of the limit still gets another go.
1702 let once = Facts {
1703 check_status: Some(CheckStatus::Failed),
1704 revisions: MAX_REVISIONS - 1,
1705 ..facts()
1706 };
1707 assert_eq!(outcome(once), (Stage::Revising, "revise for checks"));
1708 }
1709
1710 #[test]
1711 fn a_review_that_could_not_be_written_is_not_retried() {
1712 let broken = Facts {
1713 review: reviewed(None),
1714 ..facts()
1715 };
1716 assert_eq!(outcome(broken), (Stage::NeedsYou, "wait"));
1717 }
1718
1719 #[test]
1720 fn being_behind_only_holds_a_change_up_where_the_repository_says_so() {
1721 let behind = || Facts {
1722 review: reviewed(Some(Verdict::Approve)),
1723 behind: true,
1724 ..facts()
1725 };
1726 // By default it is ready as it is; merging brings it up to date.
1727 assert_eq!(outcome(behind()), (Stage::Ready, "wait"));
1728 let strict = Facts {
1729 require_up_to_date: true,
1730 ..behind()
1731 };
1732 assert_eq!(outcome(strict), (Stage::CatchingUp, "catch up"));
1733 let current = Facts {
1734 review: reviewed(Some(Verdict::Approve)),
1735 ..facts()
1736 };
1737 assert_eq!(outcome(current), (Stage::Ready, "wait"));
1738 }
1739
1740 #[test]
1741 fn a_ready_change_lands_by_itself_only_where_the_repository_says_so() {
1742 let ready = || Facts {
1743 review: reviewed(Some(Verdict::Approve)),
1744 ..facts()
1745 };
1746 assert_eq!(outcome(ready()), (Stage::Ready, "wait"));
1747 let automatic = Facts {
1748 auto_merge: true,
1749 ..ready()
1750 };
1751 assert_eq!(outcome(automatic), (Stage::Ready, "merge"));
1752 // One that is behind is merged too: merging brings it up to date.
1753 let behind = Facts {
1754 auto_merge: true,
1755 behind: true,
1756 ..ready()
1757 };
1758 assert_eq!(outcome(behind), (Stage::Ready, "merge"));
1759 // Unless the repository wants it caught up and checked again first.
1760 let strict = Facts {
1761 auto_merge: true,
1762 behind: true,
1763 require_up_to_date: true,
1764 ..ready()
1765 };
1766 assert_eq!(outcome(strict), (Stage::CatchingUp, "catch up"));
1767 // Nothing short of approved is merged, whatever the setting.
1768 let failing = Facts {
1769 auto_merge: true,
1770 check_status: Some(CheckStatus::Failed),
1771 ..ready()
1772 };
1773 assert_eq!(outcome(failing), (Stage::Revising, "revise for checks"));
1774 let unreviewed = Facts {
1775 auto_merge: true,
1776 ..facts()
1777 };
1778 assert_eq!(outcome(unreviewed), (Stage::Reviewing, "review"));
1779 }
1780
1781 #[test]
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1782 fn a_conflict_found_ahead_of_time_is_resolved_before_merging() {
1783 let conflicting = || Facts {
1784 review: reviewed(Some(Verdict::Approve)),
1785 behind: true,
1786 conflicting: true,
1787 ..facts()
1788 };
1789 // Even where the repository would merge one that is merely behind.
1790 assert_eq!(outcome(conflicting()), (Stage::CatchingUp, "catch up"));
1791 let automatic = Facts {
1792 auto_merge: true,
1793 ..conflicting()
1794 };
1795 assert_eq!(outcome(automatic), (Stage::CatchingUp, "catch up"));
1796 // Failed checks come first: a revision merges the branch in too.
1797 let failing = Facts {
1798 check_status: Some(CheckStatus::Failed),
1799 ..conflicting()
1800 };
1801 assert_eq!(outcome(failing), (Stage::Revising, "revise for checks"));
1802 }
1803
1804 #[test]
Agents as a team: lifecycle, merge queue, billing and a new shell1805 fn catching_up_reruns_the_checks_but_not_the_review() {
1806 // The merge moved the head, so the checks are waited for again.
1807 let merged_in = Facts {
1808 review: reviewed(Some(Verdict::Approve)),
Fast pages, required checks on the branch, self-hosted runners, honest incidents1809 workflows: checks(&[], &["CI"]),
Agents as a team: lifecycle, merge queue, billing and a new shell1810 ..facts()
1811 };
1812 assert_eq!(outcome(merged_in), (Stage::Checking, "wait"));
1813 }
1814
1815 #[test]
1816 fn a_step_under_way_is_not_started_again() {
1817 for (step, stage) in [
1818 ("review", Stage::Reviewing),
1819 ("revision", Stage::Revising),
1820 ("catch_up", Stage::CatchingUp),
1821 ] {
1822 let busy = Facts {
1823 working_on: Some(step.to_owned()),
1824 // Whatever else is true, the step in hand comes first.
1825 check_status: Some(CheckStatus::Failed),
1826 ..facts()
1827 };
1828 assert_eq!(outcome(busy), (stage, "wait"));
1829 }
1830 let asked = Facts {
1831 review_pending: true,
1832 ..facts()
1833 };
1834 assert_eq!(outcome(asked), (Stage::Reviewing, "wait"));
1835 }
1836
1837 #[test]
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1838 fn a_low_confidence_change_waits_for_a_person_instead_of_merging() {
1839 let held = || Facts {
1840 review: reviewed(Some(Verdict::Approve)),
1841 auto_merge: true,
1842 low_confidence: Some("tests not added, 3 revisions".to_owned()),
1843 ..facts()
1844 };
1845 let (lifecycle, next) = decide(held());
1846 assert_eq!(lifecycle.stage, Stage::NeedsYou);
1847 assert!(matches!(next, Next::Wait), "auto-merge must not land it");
1848 assert_eq!(
1849 lifecycle.detail,
1850 "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."
1851 );
1852 // Without auto-merge it needs someone too, and says why.
1853 assert_eq!(outcome(Facts { auto_merge: false, ..held() }), (Stage::NeedsYou, "wait"));
1854 // Not held (the setting is off, or a person approved): it lands.
1855 assert_eq!(outcome(Facts { low_confidence: None, ..held() }), (Stage::Ready, "merge"));
1856 // It holds only a change that is otherwise ready: what comes first,
1857 // such as failed checks, is still dealt with first.
1858 let failing = Facts {
1859 check_status: Some(CheckStatus::Failed),
1860 ..held()
1861 };
1862 assert_eq!(outcome(failing), (Stage::Revising, "revise for checks"));
1863 let unapproved = Facts {
1864 approvals_missing: Some("This repository requires 1 approving review.".to_owned()),
1865 ..held()
1866 };
1867 assert_eq!(decide(unapproved).0.detail, "This repository requires 1 approving review.");
1868 }
1869
1870 #[test]
Agents as a team: lifecycle, merge queue, billing and a new shell1871 fn once_stopped_it_stays_stopped() {
1872 let (lifecycle, next) = decide(Facts {
1873 stalled: Some("The agent could not catch up.".to_owned()),
1874 review: reviewed(Some(Verdict::Approve)),
1875 behind: true,
1876 ..facts()
1877 });
1878 assert_eq!(lifecycle.stage, Stage::NeedsYou);
1879 assert_eq!(lifecycle.detail, "The agent could not catch up.");
1880 assert!(matches!(next, Next::Wait));
1881 }
1882}