Skip to content
1,943 linesCodeBlameRaw

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.

Two limits, real invoices, trust that grows by itself, sales signals1//! 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.
Two limits, real invoices, trust that grows by itself, sales signals9//!
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};
Two limits, real invoices, trust that grows by itself, sales signals18use 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;
Two limits, real invoices, trust that grows by itself, sales signals27use crate::reviews::{AGENT_ID, AGENT_NAME};
28use crate::statuses::{self, WorkflowFacts};
29use 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.
38pub(crate) const POLICY_ACTOR_ID: &str = "g1t_policy";
39pub(crate) const POLICY_ACTOR_NAME: &str = "g1t";
40/// 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;
Two limits, real invoices, trust that grows by itself, sales signals47const 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.
Two limits, real invoices, trust that grows by itself, sales signals91 FailedChecks,
Fast pages, required checks on the branch, self-hosted runners, honest incidents92 /// Checks that failed on its head.
Two limits, real invoices, trust that grows by itself, sales signals93 FailedWorkflows,
94 /// 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
Two limits, real invoices, trust that grows by itself, sales signals132fn 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.
Two limits, real invoices, trust that grows by itself, sales signals157 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,
Two limits, real invoices, trust that grows by itself, sales signals167 /// 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.
Two limits, real invoices, trust that grows by itself, sales signals182 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>,
Two limits, real invoices, trust that grows by itself, sales signals186}
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.
Two limits, real invoices, trust that grows by itself, sales signals197fn 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.");
Two limits, real invoices, trust that grows by itself, sales signals204 }
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 }
236 Some("answer") => {
237 return wait(
238 Stage::Answering,
239 "The agent is answering what another agent asked it.",
240 );
241 }
242 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."),
Two limits, real invoices, trust that grows by itself, sales signals244 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.");
Two limits, real invoices, trust that grows by itself, sales signals248 }
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 );
Two limits, real invoices, trust that grows by itself, sales signals283 }
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 _ => {}
Two limits, real invoices, trust that grows by itself, sales signals298 }
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.
Two limits, real invoices, trust that grows by itself, sales signals302 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() {
Two limits, real invoices, trust that grows by itself, sales signals316 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 ),
Two limits, real invoices, trust that grows by itself, sales signals325 );
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 }
Two limits, real invoices, trust that grows by itself, sales signals345
346 // 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.",
Two limits, real invoices, trust that grows by itself, sales signals354 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 }
Two limits, real invoices, trust that grows by itself, sales signals406 // 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.
Two limits, real invoices, trust that grows by itself, sales signals408 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 }
Two limits, real invoices, trust that grows by itself, sales signals433 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>)>> {
Two limits, real invoices, trust that grows by itself, sales signals477 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 {
Two limits, real invoices, trust that grows by itself, sales signals481 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
Two limits, real invoices, trust that grows by itself, sales signals499 /// 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>,
Two limits, real invoices, trust that grows by itself, sales signals520 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>)>> {
Two limits, real invoices, trust that grows by itself, sales signals522 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 {
Two limits, real invoices, trust that grows by itself, sales signals541 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 }
Two limits, real invoices, trust that grows by itself, sales signals562 // 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 });
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar570 let settings = self.settings_for(pull).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 };
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge604 // The rules of the branch it merges into, as g1t sees them when it
605 // merges by itself: what people must still do, whether an agent's
606 // change may land here unattended, and a cost cap that holds the
607 // agent until a person approves.
608 let gate = match self.repo_for(pull).await? {
609 Some(repo) => Some(self.merge_gate(&repo, pull, Some(&User::system(&repo.namespace)), false, true).await?),
610 None => None,
611 };
612 let level = confidence.as_ref().map(|confidence| confidence.level).or(pull.confidence.as_ref().map(|c| c.level));
613 let rules_allow_auto_merge = gate
614 .as_ref()
615 .is_none_or(|gate| g1t_rules::merge::auto_merge_refusal(&gate.requirements, level).is_none());
616 let held = gate.as_ref().and_then(|gate| {
617 g1t_rules::outcome::blocking(&gate.judged)
618 .into_iter()
619 .find(|violation| violation.rule == "cost_cap")
620 .map(|violation| format!("{} {}", violation.message, violation.remedy))
621 });
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step622 let (lifecycle, next) = decide(Facts {
Two limits, real invoices, trust that grows by itself, sales signals623 draft: pull.status == PullStatus::Draft,
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge624 stalled: progress.stalled.or(held),
Two limits, real invoices, trust that grows by itself, sales signals625 working_on,
626 check_status: pull.check_status,
627 review_pending: self.review_pending(&pull.id).await?,
628 revisions: progress.revisions,
629 review,
630 behind,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains631 conflicting: self.conflicting_files(pull).await?.is_some(),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge632 auto_merge: settings.auto_merge && rules_allow_auto_merge,
Two limits, real invoices, trust that grows by itself, sales signals633 require_up_to_date: settings.require_up_to_date,
634 agent_review: settings.agent_review,
635 max_revisions: settings.max_revisions,
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge636 approvals_missing: gate.as_ref().and_then(|gate| gate.people_gap()),
Two limits, real invoices, trust that grows by itself, sales signals637 person_request: self
638 .person_request(pull, progress.revised_at.as_deref())
639 .await?,
640 queued: self.queued_entry(&pull.id).await?,
Fast pages, required checks on the branch, self-hosted runners, honest incidents641 workflows,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step642 low_confidence,
643 });
644 Ok(Some((lifecycle, next, confidence)))
Two limits, real invoices, trust that grows by itself, sales signals645 }
646
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights647 /// The latest request for changes by a person other than its owner
648 /// (whoever asked g1t for it, or its author), if it is that person's
649 /// latest verdict and came after the last revision.
Two limits, real invoices, trust that grows by itself, sales signals650 async fn person_request(
651 &self,
652 pull: &Pull,
653 revised_at: Option<&str>,
654 ) -> Result<Option<PersonRequest>> {
655 #[derive(Deserialize)]
656 struct Verdicts {
657 author_id: String,
658 author_name: String,
659 verdict: Verdict,
660 created_at: String,
661 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily662 let rows = match self.prefetched_pull(&pull.id) {
663 Some(found) => found
664 .rows::<Verdicts>(Slot::Verdicts)?
665 .into_iter()
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights666 .filter(|row| from_someone_else(pull, &row.author_id))
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily667 .collect(),
668 None => self
669 .db
670 .prepare(
671 "SELECT author_id, author_name, verdict, created_at FROM comments
672 WHERE repo_id = ? AND number = ? AND verdict IS NOT NULL
673 AND author_id != ? AND author_id != ?
674 ORDER BY id",
675 )
676 .bind(&[
677 pull.repo_id.as_str().into(),
678 pull.number.into(),
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights679 pull.owner().id.as_str().into(),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily680 AGENT_ID.into(),
681 ])?
682 .all()
683 .await?
684 .results::<Verdicts>()?,
685 };
Two limits, real invoices, trust that grows by itself, sales signals686 // Each person's latest verdict is the one that stands.
687 let mut latest: HashMap<String, Verdicts> = HashMap::new();
688 for row in rows {
689 latest.insert(row.author_id.clone(), row);
690 }
691 Ok(latest
692 .into_values()
693 .filter(|row| row.verdict == Verdict::RequestChanges)
694 .filter(|row| revised_at.is_none_or(|revised| row.created_at.as_str() > revised))
695 .max_by(|a, b| a.created_at.cmp(&b.created_at))
696 .map(|row| PersonRequest {
697 author_id: row.author_id,
698 author_name: row.author_name,
699 created_at: row.created_at,
700 }))
701 }
702
703 /// Marks a pull request a g1t agent has just opened as one g1t sees
704 /// through.
705 pub(crate) async fn manage(&self, pull: &Pull) -> Result<()> {
706 if !made_by_g1t(pull) {
707 return Ok(());
708 }
709 self.db
710 .prepare("UPDATE pulls SET managed = 1 WHERE id = ?")
711 .bind(&[pull.id.as_str().into()])?
712 .run()
713 .await?;
714 Ok(())
715 }
716
717 /// Takes a step for a pull request, if nobody else has. One statement,
718 /// so that two callers cannot both take it.
719 pub(crate) async fn claim(&self, pull_id: &str, step: &str, minutes: u64, revising: bool) -> Result<bool> {
720 let now = now_ms();
721 let revision = if revising {
722 ", revisions = revisions + 1, revised_at = ?1"
723 } else {
724 ""
725 };
726 Ok(self
727 .db
728 .prepare(format!(
729 "UPDATE pulls SET working_on = ?2, working_until = ?3{revision}
730 WHERE id = ?4 AND status = 'open' AND stalled IS NULL
731 AND (working_until IS NULL OR working_until < ?1)
732 RETURNING id AS value"
733 ))
734 .bind(&[
735 rfc3339(now).into(),
736 step.into(),
737 rfc3339(now + minutes * 60 * 1000).into(),
738 pull_id.into(),
739 ])?
740 .first::<ValueRow>(None)
741 .await?
742 .is_some())
743 }
744
745 /// What the author is told when sent back: the checks that failed and
Fast pages, required checks on the branch, self-hosted runners, honest incidents746 /// what their failing jobs printed, why the merge queue took it out, or
747 /// the review and its comments on lines.
Two limits, real invoices, trust that grows by itself, sales signals748 async fn feedback(&self, pull: &Pull, feedback: &Feedback) -> Result<String> {
749 match feedback {
750 Feedback::FailedChecks => {
751 let run = self.latest_checks(&pull.id).await?;
752 // Why it failed, when that is more than a list of commands:
753 // the merge queue saying what broke in the combined state.
754 let why = run.as_ref().and_then(|run| run.error.clone()).map(|error| format!("{error}\n\n")).unwrap_or_default();
755 let failed: Vec<String> = run
756 .map(|run| run.results)
757 .unwrap_or_default()
758 .into_iter()
759 .filter(|result| !result.passed)
760 .map(|result| {
761 let length = result.output.chars().count();
762 let output: String = result
763 .output
764 .chars()
765 .skip(length.saturating_sub(MAX_CHECK_OUTPUT_CHARS))
766 .collect();
767 let exit = result
768 .exit_code
769 .map_or("it was stopped for taking too long".to_owned(), |code| {
770 format!("exit code {code}")
771 });
772 format!("`{}` failed ({exit}):\n\n{}", result.command, output.trim())
773 })
774 .collect();
775 if failed.is_empty() {
776 return Ok(format!(
777 "{why}Find the cause, fix it in your change, and push. Use get_workflow_run and get_job_logs for any workflow named above."
778 ));
779 }
780 Ok(format!(
Fast pages, required checks on the branch, self-hosted runners, honest incidents781 "{why}These commands failed against your change.\n\n{}",
Two limits, real invoices, trust that grows by itself, sales signals782 failed.join("\n\n")
783 ))
784 }
785 Feedback::FailedWorkflows => {
Fast pages, required checks on the branch, self-hosted runners, honest incidents786 let (statuses, settings) = futures_util::future::try_join(
787 self.statuses(&pull.repo_id, pull.head_commit.as_deref()),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar788 self.settings_for(pull),
Fast pages, required checks on the branch, self-hosted runners, honest incidents789 )
790 .await?;
791 let required = |context: &str| {
792 let name = g1t_contracts::work::check_name(context).0;
793 settings.required_checks.iter().any(|wanted| wanted.eq_ignore_ascii_case(name))
794 };
795 let failing: Vec<&CommitStatus> =
796 statuses.iter().filter(|s| s.state == "failure" || s.state == "error").collect();
797 let failed: Vec<String> = failing
Two limits, real invoices, trust that grows by itself, sales signals798 .iter()
799 .map(|s| {
800 let run = s.target_url.as_deref().and_then(|url| url.rsplit('/').next()).unwrap_or_default();
801 format!(
Fast pages, required checks on the branch, self-hosted runners, honest incidents802 "- {}{} ({}): run `{run}`, {}",
Two limits, real invoices, trust that grows by itself, sales signals803 s.context,
Fast pages, required checks on the branch, self-hosted runners, honest incidents804 if required(&s.context) { ", required to merge" } else { "" },
Two limits, real invoices, trust that grows by itself, sales signals805 s.description.as_deref().unwrap_or("failed"),
806 s.target_url.as_deref().unwrap_or_default()
807 )
808 })
809 .collect();
Fast pages, required checks on the branch, self-hosted runners, honest incidents810 let logs = self.failing_logs(pull, &failing).await.unwrap_or_default();
Two limits, real invoices, trust that grows by itself, sales signals811 // Named outright: the agent cannot guess it from its fork.
812 let repo = g1t_kit::call::<_, Option<RepoPath>>(
813 &self.repos,
814 "path_by_id",
815 &g1t_contracts::repos::PathByIdArgs { id: pull.repo_id.clone() },
816 )
817 .await?
818 .map(|path| format!("{}/{}", path.namespace, path.name))
819 .unwrap_or_default();
Fast pages, required checks on the branch, self-hosted runners, honest incidents820 let logs = if logs.is_empty() {
821 String::new()
822 } else {
823 format!("\n\nThe end of what the failing jobs printed:\n\n{}", logs.join("\n\n"))
824 };
Two limits, real invoices, trust that grows by itself, sales signals825 Ok(format!(
Fast pages, required checks on the branch, self-hosted runners, honest incidents826 "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\
827 For more, use the `get_workflow_run` tool (repo `{repo}` and the run's id), \
Two limits, real invoices, trust that grows by itself, sales signals828 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 incidents829 unless the workflow itself is wrong. Push, and the checks run again.",
Two limits, real invoices, trust that grows by itself, sales signals830 failed.join("\n")
831 ))
832 }
833 Feedback::Review(finished_at) => {
834 // Everything a review says is recorded at the moment it finished.
835 let notes = self
836 .db
837 .prepare(
838 "SELECT body, path, line FROM comments
839 WHERE repo_id = ? AND number = ? AND author_id = ? AND created_at = ?
840 ORDER BY id",
841 )
842 .bind(&[
843 pull.repo_id.as_str().into(),
844 pull.number.into(),
845 AGENT_ID.into(),
846 finished_at.as_str().into(),
847 ])?
848 .all()
849 .await?
850 .results::<ReviewNote>()?;
851 let mut on_lines = Vec::new();
852 let mut summary = String::new();
853 for note in notes {
854 match (note.path, note.line) {
855 (Some(path), Some(line)) => {
856 on_lines.push(format!("- `{path}` line {line}: {}", note.body));
857 }
858 (Some(path), None) => on_lines.push(format!("- `{path}`: {}", note.body)),
859 (None, _) => summary = note.body,
860 }
861 }
862 let mut text = format!(
863 "Another agent reviewed your change and asked for changes.\n\n{summary}"
864 );
865 if !on_lines.is_empty() {
866 text.push_str("\n\nIts comments on lines:\n");
867 text.push_str(&on_lines.join("\n"));
868 }
869 Ok(text)
870 }
871 Feedback::Person(request) => {
872 // What they wrote since the agent last revised, which their
873 // request for changes closes.
874 let revised: Option<String> = self
875 .db
876 .prepare("SELECT revised_at AS value FROM pulls WHERE id = ?")
877 .bind(&[pull.id.as_str().into()])?
878 .first::<Option<String>>(Some("value"))
879 .await?
880 .flatten();
881 let notes = self
882 .db
883 .prepare(
884 "SELECT body, path, line FROM comments
885 WHERE repo_id = ? AND number = ? AND author_id = ?
886 AND created_at > ? AND created_at <= ?
887 ORDER BY id",
888 )
889 .bind(&[
890 pull.repo_id.as_str().into(),
891 pull.number.into(),
892 request.author_id.as_str().into(),
893 revised.unwrap_or_default().into(),
894 request.created_at.as_str().into(),
895 ])?
896 .all()
897 .await?
898 .results::<ReviewNote>()?;
899 let mut on_lines = Vec::new();
900 let mut said = Vec::new();
901 for note in notes {
902 match (note.path, note.line) {
903 (Some(path), Some(line)) => {
904 on_lines.push(format!("- `{path}` line {line}: {}", note.body));
905 }
906 (Some(path), None) => on_lines.push(format!("- `{path}`: {}", note.body)),
907 (None, _) => said.push(note.body),
908 }
909 }
910 let mut text = format!(
911 "{} reviewed your change and asked for changes.\n\n{}",
912 request.author_name,
913 said.join("\n\n")
914 );
915 if !on_lines.is_empty() {
916 text.push_str("\n\nTheir comments on lines:\n");
917 text.push_str(&on_lines.join("\n"));
918 }
919 Ok(text)
920 }
921 }
922 }
923
Fast pages, required checks on the branch, self-hosted runners, honest incidents924 /// The end of what the failed jobs of failing workflow runs printed, a
925 /// few jobs at most, for an agent sent back to fix them. Empty where the
926 /// runs or their logs cannot be read.
927 async fn failing_logs(&self, pull: &Pull, failing: &[&CommitStatus]) -> Result<Vec<String>> {
928 use g1t_contracts::actions::{JobLog, LogsArgs, RunArgs, RunDetail};
929 let Some(repo) = g1t_kit::call::<_, Option<RepoPath>>(
930 &self.repos,
931 "path_by_id",
932 &g1t_contracts::repos::PathByIdArgs { id: pull.repo_id.clone() },
933 )
934 .await?
935 else {
936 return Ok(Vec::new());
937 };
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights938 let viewer = self.owner_viewer(pull).await?;
Fast pages, required checks on the branch, self-hosted runners, honest incidents939 let mut out = Vec::new();
940 for status in failing {
941 let Some(run_id) = status
942 .target_url
943 .as_deref()
944 .and_then(|url| url.split("/actions/runs/").nth(1))
945 .map(|rest| rest.split(['/', '?', '#']).next().unwrap_or_default().to_owned())
946 .filter(|id| !id.is_empty())
947 else {
948 continue;
949 };
950 let detail: Outcome<RunDetail> = g1t_kit::call(
951 &self.actions,
952 "run",
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)953 &RunArgs { repo: repo.clone(), viewer: viewer.clone(), id: run_id, attempt: None },
Fast pages, required checks on the branch, self-hosted runners, honest incidents954 )
955 .await?;
956 let Outcome::Ok(detail) = detail else { continue };
957 let failed_jobs = detail.jobs.into_iter().filter(|job| {
958 job.status == "completed" && matches!(job.conclusion.as_deref(), Some("failure" | "timed_out"))
959 });
960 for job in failed_jobs {
961 if out.len() >= MAX_FAILED_JOBS {
962 return Ok(out);
963 }
964 let mut text = String::new();
965 let mut after = 0;
966 for _ in 0..MAX_LOG_PAGES {
967 let page: Outcome<JobLog> = g1t_kit::call(
968 &self.actions,
969 "logs",
970 &LogsArgs { repo: repo.clone(), viewer: viewer.clone(), job: job.id.clone(), after },
971 )
972 .await?;
973 let Outcome::Ok(page) = page else { break };
974 let Some(last) = page.chunks.last().map(|chunk| chunk.seq) else { break };
975 for chunk in &page.chunks {
976 text.push_str(&chunk.text);
977 if !chunk.text.ends_with('\n') {
978 text.push('\n');
979 }
980 }
981 // Only the end is kept, so the start can go as it is read.
982 let length = text.chars().count();
983 if length > MAX_JOB_LOG_CHARS * 2 {
984 text = text.chars().skip(length - MAX_JOB_LOG_CHARS).collect();
985 }
986 after = last;
987 if page.chunks.len() < 500 {
988 break;
989 }
990 }
991 let length = text.chars().count();
992 let tail: String = text.chars().skip(length.saturating_sub(MAX_JOB_LOG_CHARS)).collect();
993 if !tail.trim().is_empty() {
994 out.push(format!("{} / {}:\n```\n{}\n```", status.context, job.name, tail.trim_end()));
995 }
996 }
997 }
998 Ok(out)
999 }
1000
Two limits, real invoices, trust that grows by itself, sales signals1001 pub(crate) async fn advance(&self, a: AdvanceArgs) -> Result<Advance> {
1002 let Some(pull) = self.pull_by_id(&a.pull_id).await? else {
1003 return Ok(Advance::None);
1004 };
1005 if pull.status != PullStatus::Open {
1006 return Ok(Advance::None);
1007 }
1008 // As a member: a private repository would look missing otherwise,
1009 // 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 rights1010 let viewer: Viewer = self.owner_viewer(&pull).await?;
Two limits, real invoices, trust that grows by itself, sales signals1011 let repo: Outcome<Repo> = g1t_kit::call(
1012 &self.repos,
1013 "get_by_id",
1014 &GetByIdArgs {
1015 id: pull.repo_id.clone(),
1016 viewer,
1017 },
1018 )
1019 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1020 let (Outcome::Ok(repo), Some(source)) = (crate::retired::unless_archived(repo), pull.fork.clone()) else {
Two limits, real invoices, trust that grows by itself, sales signals1021 return Ok(Advance::None);
1022 };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1023 // The branch it merges into: what it catches up with.
1024 let base = pull.base_branch(&repo.default_branch).to_owned();
Two limits, real invoices, trust that grows by itself, sales signals1025 let issue = match pull.issue {
1026 Some(number) => self.issue(&pull.repo_id, number).await?,
1027 None => None,
1028 };
1029 let behind = self.is_behind(&repo.id, &pull).await?;
1030 let Some((lifecycle, next)) = self.assess(&pull, &issue, behind).await? else {
1031 return Ok(Advance::None);
1032 };
1033
1034 if matches!(next, Next::Merge) {
1035 self.merge_by_policy(&repo, &pull).await?;
1036 return Ok(Advance::None);
1037 }
1038 let (step, minutes) = match &next {
1039 Next::Wait | Next::Merge => return Ok(Advance::None),
1040 Next::Review => ("review", REVIEW_MINUTES),
1041 Next::Revise(_) => ("revision", REVISION_MINUTES),
1042 Next::CatchUp => ("catch_up", CATCH_UP_MINUTES),
1043 };
1044 let feedback = match &next {
1045 Next::Revise(feedback) => self.feedback(&pull, feedback).await?,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1046 Next::CatchUp => self.conflict_note(&pull, &base).await?,
Two limits, real invoices, trust that grows by itself, sales signals1047 _ => String::new(),
1048 };
1049 if !self
1050 .claim(&pull.id, step, minutes, matches!(next, Next::Revise(_)))
1051 .await?
1052 {
1053 return Ok(Advance::None);
1054 }
1055 // Said in the conversation, so nobody has to wonder why a review or
1056 // a new commit appeared.
1057 let told = match &next {
1058 Next::Review => {
1059 self.db
1060 .prepare(
1061 "UPDATE pulls SET reviewers = json_insert(reviewers, '$[#]', ?1)
1062 WHERE id = ?2 AND NOT EXISTS (
1063 SELECT 1 FROM json_each(pulls.reviewers) WHERE json_each.value = ?1)",
1064 )
1065 .bind(&[AGENT_NAME.into(), pull.id.as_str().into()])?
1066 .run()
1067 .await?;
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1068 "requested a review from g1t".to_owned()
Two limits, real invoices, trust that grows by itself, sales signals1069 }
1070 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-agent1071 "sent g1t back to fix what failed in the merge queue".to_owned()
Two limits, real invoices, trust that grows by itself, sales signals1072 }
1073 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-agent1074 "sent g1t back to fix the failed checks".to_owned()
Two limits, real invoices, trust that grows by itself, sales signals1075 }
1076 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-agent1077 "sent g1t back to address the review".to_owned()
Two limits, real invoices, trust that grows by itself, sales signals1078 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1079 _ => format!("asked g1t to bring this up to date with {base}"),
Two limits, real invoices, trust that grows by itself, sales signals1080 };
1081 self.note(
1082 &pull.repo_id,
1083 pull.number,
1084 (POLICY_ACTOR_ID, POLICY_ACTOR_NAME),
1085 &told,
1086 )
1087 .await?;
1088 let job = LifecycleJob {
1089 pull_id: pull.id,
1090 repo: RepoPath {
1091 namespace: repo.namespace,
1092 name: repo.name,
1093 },
1094 number: pull.number,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1095 author: pull.requested_by.unwrap_or(pull.author),
Two limits, real invoices, trust that grows by itself, sales signals1096 source,
1097 branch: None,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1098 default_branch: base,
Two limits, real invoices, trust that grows by itself, sales signals1099 title: pull.title,
1100 description: pull.body.unwrap_or_default(),
1101 issue,
1102 feedback,
1103 round: lifecycle.revisions + 1,
1104 };
1105 Ok(match next {
1106 Next::Review => Advance::Review { job },
1107 Next::Revise(_) => Advance::Revise { job },
1108 Next::CatchUp => Advance::CatchUp { job },
1109 Next::Wait | Next::Merge => Advance::None,
1110 })
1111 }
1112
1113 /// Lands a pull request that is ready, on the authority of the
1114 /// repository's settings instead of a person's click.
1115 async fn merge_by_policy(&self, repo: &Repo, pull: &Pull) -> Result<()> {
1116 if !self.claim(&pull.id, "merge", MERGE_MINUTES, false).await? {
1117 return Ok(());
1118 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1119 // g1t acts for the workspace whose members turned this on, with the
1120 // default base permission (Write), which merging needs.
Two limits, real invoices, trust that grows by itself, sales signals1121 let actor = User {
1122 id: POLICY_ACTOR_ID.to_owned(),
1123 username: POLICY_ACTOR_NAME.to_owned(),
1124 verified: true,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1125 workspaces: vec![Membership::member(repo.namespace.to_lowercase())],
Two limits, real invoices, trust that grows by itself, sales signals1126 ..User::default()
1127 };
1128 let merged = self
1129 .merge_pull(PullActionArgs {
1130 actor,
1131 repo: RepoPath {
1132 namespace: repo.namespace.clone(),
1133 name: repo.name.clone(),
1134 },
1135 number: pull.number,
1136 summary: String::new(),
1137 keep_issue_open: false,
1138 ignore_checks: false,
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1139 bypass_rules: false,
Two limits, real invoices, trust that grows by itself, sales signals1140 })
1141 .await?;
1142 match merged {
1143 Outcome::Ok(_) => Ok(()),
1144 // Most likely the branch moved in the moment between: let go, and
1145 // the next look at it will catch up and try again.
1146 Outcome::Fail(failure) if failure.code == FailureCode::Conflict => {
1147 self.db
1148 .prepare(
1149 "UPDATE pulls SET working_on = NULL, working_until = NULL
1150 WHERE id = ? AND working_on = 'merge'",
1151 )
1152 .bind(&[pull.id.as_str().into()])?
1153 .run()
1154 .await?;
1155 Ok(())
1156 }
1157 Outcome::Fail(failure) => {
1158 self.stall(StallArgs {
1159 pull_id: pull.id.clone(),
1160 reason: format!("g1t could not merge this: {}", failure.message),
Events: review requests, assignments, stops and deployments are published1161 by: None,
Two limits, real invoices, trust that grows by itself, sales signals1162 })
1163 .await?;
1164 Ok(())
1165 }
1166 }
1167 }
1168
1169 /// Records that a merge was asked for while the pull request was
1170 /// behind, and announces it so that the runner brings it up to date.
1171 pub(crate) async fn request_landing(
1172 &self,
1173 pull: &Pull,
1174 actor: &User,
1175 keep_issue_open: bool,
1176 ) -> Result<()> {
1177 let now = now_ms();
1178 let request = serde_json::to_string(&LandRequest {
1179 actor: actor.clone(),
1180 keep_issue_open,
1181 })?;
1182 self.db
1183 .prepare(
1184 "UPDATE pulls
1185 SET land_requested = ?, land_requested_at = ?, stalled = NULL,
1186 working_on = 'catch_up', working_until = ?
1187 WHERE id = ?",
1188 )
1189 .bind(&[
1190 request.into(),
1191 rfc3339(now).into(),
1192 rfc3339(now + CATCH_UP_MINUTES * 60 * 1000).into(),
1193 pull.id.as_str().into(),
1194 ])?
1195 .run()
1196 .await?;
1197 self.publish(
1198 "pull.merge_requested",
1199 &pull.repo_id,
1200 actor,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1201 Self::pull_event(pull),
Two limits, real invoices, trust that grows by itself, sales signals1202 )
1203 .await
1204 }
1205
1206 /// The merge waiting on a pull request, if one was asked for recently
1207 /// enough to still stand.
1208 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 daily1209 let row = self.land_row(pull_id).await?;
Two limits, real invoices, trust that grows by itself, sales signals1210 let oldest = rfc3339(now_ms().saturating_sub(CATCH_UP_MINUTES * 60 * 1000));
1211 Ok(row
1212 .filter(|row| {
1213 row.land_requested_at
1214 .as_deref()
1215 .is_some_and(|at| at >= oldest.as_str())
1216 })
1217 .and_then(|row| row.land_requested)
1218 .and_then(|request| serde_json::from_str(&request).ok()))
1219 }
1220
1221 /// Whether a merge is waiting on a pull request, and why g1t stopped
1222 /// working on it if it did.
1223 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 daily1224 let stalled = self.land_row(pull_id).await?.and_then(|row| row.stalled);
1225 Ok((self.land_request(pull_id).await?.is_some(), stalled))
1226 }
1227
1228 /// The merge waiting on a pull request and why g1t stopped, as read
1229 /// for this request (prefetch.rs) or now.
1230 async fn land_row(&self, pull_id: &str) -> Result<Option<LandRow>> {
1231 if let Some(found) = self.prefetched_pull(pull_id) {
1232 return found.first::<LandRow>(Slot::Pull);
1233 }
1234 self.db
Two limits, real invoices, trust that grows by itself, sales signals1235 .prepare("SELECT land_requested, land_requested_at, stalled FROM pulls WHERE id = ?")
1236 .bind(&[pull_id.into()])?
1237 .first::<LandRow>(None)
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1238 .await
Two limits, real invoices, trust that grows by itself, sales signals1239 }
1240
1241 async fn forget_landing(&self, pull_id: &str) -> Result<()> {
1242 self.db
1243 .prepare(
1244 "UPDATE pulls SET land_requested = NULL, land_requested_at = NULL WHERE id = ?",
1245 )
1246 .bind(&[pull_id.into()])?
1247 .run()
1248 .await?;
1249 Ok(())
1250 }
1251
1252 /// Lands a pull request whose head has just moved, if a merge of it was
1253 /// waiting for exactly that. What it was caught up to was already
1254 /// checked and reviewed apart from the merge, so the checks are not
1255 /// waited for again; a repository that wants them rerun turns on
1256 /// "require up to date", and then nothing is landed this way.
1257 pub(crate) async fn land_if_requested(&self, pull_id: &str) -> Result<()> {
1258 let Some(request) = self.land_request(pull_id).await? else {
1259 return Ok(());
1260 };
1261 self.forget_landing(pull_id).await?;
1262 let Some(pull) = self.pull_by_id(pull_id).await? else {
1263 return Ok(());
1264 };
1265 let repo: Outcome<Repo> = g1t_kit::call(
1266 &self.repos,
1267 "get_by_id",
1268 &GetByIdArgs {
1269 id: pull.repo_id.clone(),
1270 viewer: Some(request.actor.clone()),
1271 },
1272 )
1273 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1274 let Outcome::Ok(repo) = crate::retired::unless_archived(repo) else {
Two limits, real invoices, trust that grows by itself, sales signals1275 return Ok(());
1276 };
1277 let merged = self
1278 .merge_pull(PullActionArgs {
1279 actor: request.actor,
1280 repo: RepoPath {
1281 namespace: repo.namespace,
1282 name: repo.name,
1283 },
1284 number: pull.number,
1285 summary: String::new(),
1286 keep_issue_open: request.keep_issue_open,
1287 ignore_checks: true,
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1288 bypass_rules: false,
Two limits, real invoices, trust that grows by itself, sales signals1289 })
1290 .await?;
1291 if let Outcome::Fail(failure) = merged {
1292 self.stall(StallArgs {
1293 pull_id: pull.id,
1294 reason: format!(
1295 "It was brought up to date but could not be merged: {}",
1296 failure.message
1297 ),
Events: review requests, assignments, stops and deployments are published1298 by: None,
Two limits, real invoices, trust that grows by itself, sales signals1299 })
1300 .await?;
1301 }
1302 Ok(())
1303 }
1304
1305 /// What the runner needs to bring a pull request up to date for a merge
1306 /// that is waiting on it.
1307 pub(crate) async fn catch_up_job(&self, a: CatchUpJobArgs) -> Result<Option<LifecycleJob>> {
1308 if self.land_request(&a.pull_id).await?.is_none() {
1309 return Ok(None);
1310 }
1311 let Some(pull) = self.pull_by_id(&a.pull_id).await? else {
1312 return Ok(None);
1313 };
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1314 // Its owner (whoever asked g1t for it, or its author) can read both
1315 // the repository and the pull request's source.
Two limits, real invoices, trust that grows by itself, sales signals1316 let repo: Outcome<Repo> = g1t_kit::call(
1317 &self.repos,
1318 "get_by_id",
1319 &GetByIdArgs {
1320 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 rights1321 viewer: self.owner_viewer(&pull).await?,
Two limits, real invoices, trust that grows by itself, sales signals1322 },
1323 )
1324 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1325 let Outcome::Ok(repo) = crate::retired::unless_archived(repo) else {
Two limits, real invoices, trust that grows by itself, sales signals1326 return Ok(None);
1327 };
1328 let path = RepoPath {
1329 namespace: repo.namespace,
1330 name: repo.name,
1331 };
1332 let issue = match pull.issue {
1333 Some(number) => self.issue(&pull.repo_id, number).await?,
1334 None => None,
1335 };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1336 let base = pull.base_branch(&repo.default_branch).to_owned();
1337 let feedback = self.conflict_note(&pull, &base).await?;
Two limits, real invoices, trust that grows by itself, sales signals1338 Ok(Some(LifecycleJob {
1339 pull_id: pull.id,
1340 source: pull.fork.unwrap_or_else(|| path.clone()),
1341 repo: path,
1342 number: pull.number,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1343 author: pull.requested_by.unwrap_or(pull.author),
Two limits, real invoices, trust that grows by itself, sales signals1344 branch: pull.branch,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1345 default_branch: base,
Two limits, real invoices, trust that grows by itself, sales signals1346 title: pull.title,
1347 description: pull.body.unwrap_or_default(),
1348 issue,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1349 feedback,
Two limits, real invoices, trust that grows by itself, sales signals1350 round: 0,
1351 }))
1352 }
1353
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1354 /// For an agent catching up: the files g1t already knows conflict, so
1355 /// it reads them first. Empty when none are known.
1356 pub(crate) async fn conflict_note(&self, pull: &Pull, default_branch: &str) -> Result<String> {
1357 Ok(match self.conflicting_files(pull).await? {
1358 Some(files) if !files.is_empty() => format!(
1359 "g1t found ahead of time that merging {default_branch} into this pull request conflicts in these files: {}.",
1360 files.join(", ")
1361 ),
1362 _ => String::new(),
1363 })
1364 }
1365
Events: review requests, assignments, stops and deployments are published1366 /// Stops seeing a pull request through until a person steps in. The
1367 /// first stop is published (`pull.stalled`), which tells its people
1368 /// that it needs them; a stop on one already stopped only says why.
Two limits, real invoices, trust that grows by itself, sales signals1369 pub(crate) async fn stall(&self, a: StallArgs) -> Result<bool> {
Events: review requests, assignments, stops and deployments are published1370 let before = self.pull_by_id(&a.pull_id).await?;
1371 let was_stalled = self.is_stalled(&a.pull_id).await?;
Two limits, real invoices, trust that grows by itself, sales signals1372 self.db
1373 .prepare(
1374 "UPDATE pulls
1375 SET stalled = ?, working_on = NULL, working_until = NULL,
1376 land_requested = NULL, land_requested_at = NULL
1377 WHERE id = ? AND status = 'open'",
1378 )
1379 .bind(&[a.reason.trim().into(), a.pull_id.as_str().into()])?
1380 .run()
1381 .await?;
1382 self.db
1383 .prepare("UPDATE pulls SET stage = 'needs_you', stage_detail = ? WHERE id = ?")
Events: review requests, assignments, stops and deployments are published1384 .bind(&[a.reason.trim().into(), a.pull_id.as_str().into()])?
Two limits, real invoices, trust that grows by itself, sales signals1385 .run()
1386 .await?;
Events: review requests, assignments, stops and deployments are published1387 if let Some(pull) = before.filter(|pull| !was_stalled && pull.status == PullStatus::Open) {
1388 self.publish_as(
1389 "pull.stalled",
1390 &pull.repo_id,
1391 a.by.clone(),
1392 g1t_contracts::events::PullEvent {
1393 detail: Some(a.reason.trim().to_owned()),
1394 ..Self::pull_event(&pull)
1395 },
1396 )
1397 .await?;
1398 }
Two limits, real invoices, trust that grows by itself, sales signals1399 Ok(true)
1400 }
1401
Events: review requests, assignments, stops and deployments are published1402 /// Whether g1t has stopped seeing the pull request through.
1403 pub(crate) async fn is_stalled(&self, pull_id: &str) -> Result<bool> {
1404 Ok(self
1405 .db
1406 .prepare("SELECT 1 AS value FROM pulls WHERE id = ? AND stalled IS NOT NULL")
1407 .bind(&[pull_id.into()])?
1408 .first::<u32>(Some("value"))
1409 .await?
1410 .is_some())
1411 }
1412
1413 /// Says that a pull request g1t had stopped on is going again, which
1414 /// closes what it was waiting on a person for.
1415 pub(crate) async fn announce_resumed(&self, pull_id: &str, actor: Option<String>) -> Result<()> {
1416 if let Some(pull) = self.pull_by_id(pull_id).await? {
1417 self.publish_as("pull.resumed", &pull.repo_id, actor, Self::pull_event(&pull)).await?;
1418 }
1419 Ok(())
1420 }
1421
Two limits, real invoices, trust that grows by itself, sales signals1422 pub(crate) async fn managed_pulls(&self, a: ManagedPullsArgs) -> Result<Vec<String>> {
1423 let rows = self
1424 .db
1425 .prepare(
1426 "SELECT id AS value FROM pulls
1427 WHERE status = 'open' AND managed = 1 AND stalled IS NULL
1428 AND (?1 IS NULL OR repo_id = ?1)
1429 ORDER BY updated_at DESC LIMIT ?2",
1430 )
1431 .bind(&[
1432 a.repo_id.map_or(JsValue::NULL, JsValue::from),
1433 MANAGED_PAGE.into(),
1434 ])?
1435 .all()
1436 .await?
1437 .results::<ValueRow>()?;
1438 Ok(rows.into_iter().map(|row| row.value).collect())
1439 }
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444 use super::*;
1445
1446 const MAX_REVISIONS: u32 = 2;
1447
1448 #[test]
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1449 fn a_verdict_from_whoever_asked_for_g1t_s_change_is_not_someone_else_s() {
1450 use crate::rows::stored::{ASKER, G1T, pull};
1451 let made = pull(G1T, Some(ASKER));
1452 assert!(made_by_g1t(&made));
1453 // The person it was made for is held to what an author was: their
1454 // request for changes does not send g1t back as a reviewer's
1455 // would, and their approval does not lift a hold.
1456 assert!(!from_someone_else(&made, ASKER.0));
1457 assert!(!from_someone_else(&made, AGENT_ID));
1458 assert!(from_someone_else(&made, "usr_reviewer"));
1459 // Anyone's own pull request, the same.
1460 let own = pull(ASKER, None);
1461 assert!(!from_someone_else(&own, ASKER.0));
1462 assert!(from_someone_else(&own, "usr_reviewer"));
1463 }
1464
1465 #[test]
Two limits, real invoices, trust that grows by itself, sales signals1466 fn approvals_the_repository_wants_are_waited_for() {
1467 let short = || Facts {
1468 review: reviewed(Some(Verdict::Approve)),
1469 approvals_missing: Some("This repository requires 1 approving review.".to_owned()),
1470 ..facts()
1471 };
1472 let (lifecycle, next) = decide(short());
1473 assert_eq!(lifecycle.stage, Stage::NeedsYou);
1474 assert_eq!(
1475 lifecycle.detail,
1476 "This repository requires 1 approving review."
1477 );
1478 assert!(matches!(next, Next::Wait));
1479 // Not even a repository that merges by itself merges without them.
1480 let automatic = Facts {
1481 auto_merge: true,
1482 ..short()
1483 };
1484 assert_eq!(outcome(automatic), (Stage::NeedsYou, "wait"));
1485 }
1486
1487 #[test]
1488 fn a_repository_can_leave_review_to_people() {
1489 let unreviewed = Facts {
1490 agent_review: false,
1491 ..facts()
1492 };
1493 assert_eq!(outcome(unreviewed), (Stage::Ready, "wait"));
1494 let failing = Facts {
1495 agent_review: false,
1496 check_status: Some(CheckStatus::Failed),
1497 ..facts()
1498 };
1499 assert_eq!(outcome(failing), (Stage::Revising, "revise for checks"));
1500 }
1501
1502 #[test]
1503 fn a_repository_sets_how_often_the_author_is_sent_back() {
1504 let never = Facts {
1505 max_revisions: 0,
1506 check_status: Some(CheckStatus::Failed),
1507 ..facts()
1508 };
1509 assert_eq!(outcome(never), (Stage::NeedsYou, "wait"));
1510 }
1511
Fast pages, required checks on the branch, self-hosted runners, honest incidents1512 /// Checks on a head commit: `(context, state)` statuses, against the
1513 /// required check names.
1514 fn checks(statuses: &[(&str, &str)], required: &[&str]) -> WorkflowFacts {
1515 let statuses: Vec<CommitStatus> = statuses
1516 .iter()
1517 .map(|(context, state)| CommitStatus {
1518 context: (*context).to_owned(),
1519 state: (*state).to_owned(),
1520 description: None,
1521 target_url: None,
1522 updated_at: String::new(),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1523 source: None,
Merge checks: statuses and check runs on every commit1524 check_run_id: None,
Fast pages, required checks on the branch, self-hosted runners, honest incidents1525 })
1526 .collect();
1527 let required: Vec<String> = required.iter().map(|name| (*name).to_owned()).collect();
1528 WorkflowFacts::of(&statuses, &required)
1529 }
1530
1531 /// A pull request that is ready for review, whose required check
1532 /// passed, and nothing else yet.
Two limits, real invoices, trust that grows by itself, sales signals1533 fn facts() -> Facts {
1534 Facts {
1535 draft: false,
1536 stalled: None,
1537 working_on: None,
Fast pages, required checks on the branch, self-hosted runners, honest incidents1538 check_status: None,
Two limits, real invoices, trust that grows by itself, sales signals1539 review_pending: false,
1540 revisions: 0,
1541 review: None,
1542 behind: false,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1543 conflicting: false,
Two limits, real invoices, trust that grows by itself, sales signals1544 auto_merge: false,
1545 require_up_to_date: false,
1546 agent_review: true,
1547 max_revisions: MAX_REVISIONS,
1548 approvals_missing: None,
1549 person_request: None,
1550 queued: None,
Fast pages, required checks on the branch, self-hosted runners, honest incidents1551 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 step1552 low_confidence: None,
Two limits, real invoices, trust that grows by itself, sales signals1553 }
1554 }
1555
1556 #[test]
Fast pages, required checks on the branch, self-hosted runners, honest incidents1557 fn failed_checks_send_the_agent_back_and_running_ones_wait() {
Two limits, real invoices, trust that grows by itself, sales signals1558 let failed = Facts {
Fast pages, required checks on the branch, self-hosted runners, honest incidents1559 workflows: checks(&[("CI / pull_request", "failure")], &["CI"]),
Two limits, real invoices, trust that grows by itself, sales signals1560 ..facts()
1561 };
1562 let (lifecycle, next) = decide(failed);
1563 assert!(matches!(next, Next::Revise(Feedback::FailedWorkflows)));
1564 assert!(lifecycle.detail.contains("CI / pull_request failed"));
1565 let running = Facts {
Fast pages, required checks on the branch, self-hosted runners, honest incidents1566 workflows: checks(&[("CI / pull_request", "pending")], &["CI"]),
Two limits, real invoices, trust that grows by itself, sales signals1567 ..facts()
1568 };
1569 let (lifecycle, next) = decide(running);
1570 assert!(matches!(next, Next::Wait));
1571 assert!(lifecycle.detail.contains("Waiting for CI / pull_request"));
1572 }
1573
1574 #[test]
Fast pages, required checks on the branch, self-hosted runners, honest incidents1575 fn a_check_the_branch_does_not_require_is_fixed_but_does_not_hold_it_for_ever() {
1576 // Lint is not required: the agent is still sent back to fix it...
1577 let lint = checks(&[("CI / pull_request", "success"), ("Lint / pull_request", "failure")], &["CI"]);
1578 let failing = Facts { workflows: lint.clone(), ..facts() };
1579 assert_eq!(outcome(failing), (Stage::Revising, "revise for workflows"));
1580 // ...but once it is out of revisions, Lint no longer holds it.
1581 let exhausted = Facts {
1582 workflows: lint,
1583 revisions: MAX_REVISIONS,
1584 review: reviewed(Some(Verdict::Approve)),
1585 ..facts()
1586 };
1587 assert_eq!(outcome(exhausted), (Stage::Ready, "wait"));
1588 // A required check that still fails asks a person.
1589 let required = Facts {
1590 workflows: checks(&[("CI / pull_request", "failure")], &["CI"]),
1591 revisions: MAX_REVISIONS,
1592 ..facts()
1593 };
1594 let (lifecycle, next) = decide(required);
1595 assert_eq!(lifecycle.stage, Stage::NeedsYou);
1596 assert_eq!(lifecycle.detail, "The required check CI still fails after the agent revised twice.");
1597 assert!(matches!(next, Next::Wait));
1598 }
1599
1600 #[test]
1601 fn a_required_check_that_has_not_reported_is_waited_for() {
1602 let missing = Facts {
1603 workflows: checks(&[("CI / pull_request", "success")], &["CI", "Deploy"]),
1604 review: reviewed(Some(Verdict::Approve)),
1605 auto_merge: true,
1606 ..facts()
1607 };
1608 let (lifecycle, next) = decide(missing);
1609 assert_eq!(lifecycle.stage, Stage::Checking);
1610 assert_eq!(lifecycle.detail, "Waiting for the required check Deploy to report on its latest commit.");
1611 assert!(matches!(next, Next::Wait), "auto-merge must not land it");
1612 }
1613
1614 #[test]
Two limits, real invoices, trust that grows by itself, sales signals1615 fn a_queued_pull_request_waits_in_the_queue() {
1616 let queued = Facts {
1617 review: reviewed(Some(Verdict::Approve)),
1618 queued: Some((QueueState::Testing, vec![12, 14])),
1619 auto_merge: true,
1620 ..facts()
1621 };
1622 let (lifecycle, next) = decide(queued);
1623 assert_eq!(lifecycle.stage, Stage::Queued);
1624 assert!(lifecycle.detail.contains("#12, #14"));
1625 assert!(matches!(next, Next::Wait));
1626 }
1627
1628 fn asked_by_a_person() -> Option<PersonRequest> {
1629 Some(PersonRequest {
1630 author_id: "usr_reviewer".to_owned(),
1631 author_name: "g1t-reviewer".to_owned(),
1632 created_at: "2026-10-02T11:00:00.000Z".to_owned(),
1633 })
1634 }
1635
1636 #[test]
1637 fn a_person_asking_for_changes_sends_the_agent_back() {
1638 let asked = Facts {
1639 review: reviewed(Some(Verdict::Approve)),
1640 approvals_missing: Some("A reviewer has asked for changes.".to_owned()),
1641 person_request: asked_by_a_person(),
1642 ..facts()
1643 };
1644 let (lifecycle, _) = decide(Facts {
1645 person_request: asked_by_a_person(),
1646 ..facts()
1647 });
1648 assert!(lifecycle.detail.starts_with("g1t-reviewer asked for changes"));
1649 assert_eq!(outcome(asked), (Stage::Revising, "revise for a person"));
1650 }
1651
1652 #[test]
1653 fn a_person_is_asked_once_the_revisions_run_out() {
1654 let exhausted = Facts {
1655 person_request: asked_by_a_person(),
1656 revisions: MAX_REVISIONS,
1657 ..facts()
1658 };
1659 assert_eq!(outcome(exhausted), (Stage::NeedsYou, "wait"));
1660 }
1661
1662 fn reviewed(verdict: Option<Verdict>) -> Option<FinishedReview> {
1663 Some(FinishedReview {
1664 finished_at: "2026-10-02T10:00:00.000Z".to_owned(),
1665 verdict,
1666 })
1667 }
1668
1669 /// The stage, and a word for the step to take.
1670 fn outcome(facts: Facts) -> (Stage, &'static str) {
1671 let (lifecycle, next) = decide(facts);
1672 let step = match next {
1673 Next::Wait => "wait",
1674 Next::Review => "review",
1675 Next::Revise(Feedback::FailedChecks) => "revise for checks",
1676 Next::Revise(Feedback::FailedWorkflows) => "revise for workflows",
1677 Next::Revise(Feedback::Review(_)) => "revise for review",
1678 Next::Revise(Feedback::Person(_)) => "revise for a person",
1679 Next::CatchUp => "catch up",
1680 Next::Merge => "merge",
1681 };
1682 (lifecycle.stage, step)
1683 }
1684
1685 #[test]
1686 fn nothing_is_started_while_the_agent_is_still_working() {
1687 let draft = Facts {
1688 draft: true,
1689 check_status: None,
1690 ..facts()
1691 };
1692 assert_eq!(outcome(draft), (Stage::Working, "wait"));
1693 }
1694
1695 #[test]
1696 fn checks_come_before_review() {
1697 let unchecked = Facts {
Fast pages, required checks on the branch, self-hosted runners, honest incidents1698 workflows: checks(&[], &["CI"]),
Two limits, real invoices, trust that grows by itself, sales signals1699 ..facts()
1700 };
1701 assert_eq!(outcome(unchecked), (Stage::Checking, "wait"));
1702 let running = Facts {
Fast pages, required checks on the branch, self-hosted runners, honest incidents1703 workflows: checks(&[("CI / pull_request", "pending")], &["CI"]),
Two limits, real invoices, trust that grows by itself, sales signals1704 ..facts()
1705 };
1706 assert_eq!(outcome(running), (Stage::Checking, "wait"));
1707 assert_eq!(outcome(facts()), (Stage::Reviewing, "review"));
1708 }
1709
1710 #[test]
Fast pages, required checks on the branch, self-hosted runners, honest incidents1711 fn a_branch_that_requires_no_checks_goes_straight_to_review() {
Two limits, real invoices, trust that grows by itself, sales signals1712 let unchecked = Facts {
Fast pages, required checks on the branch, self-hosted runners, honest incidents1713 workflows: WorkflowFacts::default(),
Two limits, real invoices, trust that grows by itself, sales signals1714 ..facts()
1715 };
1716 assert_eq!(outcome(unchecked), (Stage::Reviewing, "review"));
1717 }
1718
1719 #[test]
Fast pages, required checks on the branch, self-hosted runners, honest incidents1720 fn failing_in_the_merge_queue_sends_the_author_back() {
Two limits, real invoices, trust that grows by itself, sales signals1721 let failed = Facts {
1722 check_status: Some(CheckStatus::Failed),
1723 ..facts()
1724 };
Fast pages, required checks on the branch, self-hosted runners, honest incidents1725 let (lifecycle, next) = decide(failed);
1726 assert_eq!(lifecycle.detail, "It failed in the merge queue. The agent is being sent back to fix it.");
1727 assert!(matches!(next, Next::Revise(Feedback::FailedChecks)));
Two limits, real invoices, trust that grows by itself, sales signals1728 }
1729
1730 #[test]
1731 fn checks_that_could_not_run_are_a_persons_problem() {
1732 let errored = Facts {
1733 check_status: Some(CheckStatus::Errored),
1734 ..facts()
1735 };
1736 assert_eq!(outcome(errored), (Stage::NeedsYou, "wait"));
1737 }
1738
1739 #[test]
1740 fn a_review_asking_for_changes_sends_the_author_back() {
1741 let changes = Facts {
1742 review: reviewed(Some(Verdict::RequestChanges)),
1743 ..facts()
1744 };
1745 assert_eq!(outcome(changes), (Stage::Revising, "revise for review"));
1746 }
1747
1748 #[test]
1749 fn the_author_is_sent_back_only_so_many_times() {
1750 let failing = Facts {
1751 check_status: Some(CheckStatus::Failed),
1752 revisions: MAX_REVISIONS,
1753 ..facts()
1754 };
1755 assert_eq!(outcome(failing), (Stage::NeedsYou, "wait"));
1756 let unconvinced = Facts {
1757 review: reviewed(Some(Verdict::RequestChanges)),
1758 revisions: MAX_REVISIONS,
1759 ..facts()
1760 };
1761 assert_eq!(outcome(unconvinced), (Stage::NeedsYou, "wait"));
1762 // One short of the limit still gets another go.
1763 let once = Facts {
1764 check_status: Some(CheckStatus::Failed),
1765 revisions: MAX_REVISIONS - 1,
1766 ..facts()
1767 };
1768 assert_eq!(outcome(once), (Stage::Revising, "revise for checks"));
1769 }
1770
1771 #[test]
1772 fn a_review_that_could_not_be_written_is_not_retried() {
1773 let broken = Facts {
1774 review: reviewed(None),
1775 ..facts()
1776 };
1777 assert_eq!(outcome(broken), (Stage::NeedsYou, "wait"));
1778 }
1779
1780 #[test]
1781 fn being_behind_only_holds_a_change_up_where_the_repository_says_so() {
1782 let behind = || Facts {
1783 review: reviewed(Some(Verdict::Approve)),
1784 behind: true,
1785 ..facts()
1786 };
1787 // By default it is ready as it is; merging brings it up to date.
1788 assert_eq!(outcome(behind()), (Stage::Ready, "wait"));
1789 let strict = Facts {
1790 require_up_to_date: true,
1791 ..behind()
1792 };
1793 assert_eq!(outcome(strict), (Stage::CatchingUp, "catch up"));
1794 let current = Facts {
1795 review: reviewed(Some(Verdict::Approve)),
1796 ..facts()
1797 };
1798 assert_eq!(outcome(current), (Stage::Ready, "wait"));
1799 }
1800
1801 #[test]
1802 fn a_ready_change_lands_by_itself_only_where_the_repository_says_so() {
1803 let ready = || Facts {
1804 review: reviewed(Some(Verdict::Approve)),
1805 ..facts()
1806 };
1807 assert_eq!(outcome(ready()), (Stage::Ready, "wait"));
1808 let automatic = Facts {
1809 auto_merge: true,
1810 ..ready()
1811 };
1812 assert_eq!(outcome(automatic), (Stage::Ready, "merge"));
1813 // One that is behind is merged too: merging brings it up to date.
1814 let behind = Facts {
1815 auto_merge: true,
1816 behind: true,
1817 ..ready()
1818 };
1819 assert_eq!(outcome(behind), (Stage::Ready, "merge"));
1820 // Unless the repository wants it caught up and checked again first.
1821 let strict = Facts {
1822 auto_merge: true,
1823 behind: true,
1824 require_up_to_date: true,
1825 ..ready()
1826 };
1827 assert_eq!(outcome(strict), (Stage::CatchingUp, "catch up"));
1828 // Nothing short of approved is merged, whatever the setting.
1829 let failing = Facts {
1830 auto_merge: true,
1831 check_status: Some(CheckStatus::Failed),
1832 ..ready()
1833 };
1834 assert_eq!(outcome(failing), (Stage::Revising, "revise for checks"));
1835 let unreviewed = Facts {
1836 auto_merge: true,
1837 ..facts()
1838 };
1839 assert_eq!(outcome(unreviewed), (Stage::Reviewing, "review"));
1840 }
1841
1842 #[test]
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1843 fn a_conflict_found_ahead_of_time_is_resolved_before_merging() {
1844 let conflicting = || Facts {
1845 review: reviewed(Some(Verdict::Approve)),
1846 behind: true,
1847 conflicting: true,
1848 ..facts()
1849 };
1850 // Even where the repository would merge one that is merely behind.
1851 assert_eq!(outcome(conflicting()), (Stage::CatchingUp, "catch up"));
1852 let automatic = Facts {
1853 auto_merge: true,
1854 ..conflicting()
1855 };
1856 assert_eq!(outcome(automatic), (Stage::CatchingUp, "catch up"));
1857 // Failed checks come first: a revision merges the branch in too.
1858 let failing = Facts {
1859 check_status: Some(CheckStatus::Failed),
1860 ..conflicting()
1861 };
1862 assert_eq!(outcome(failing), (Stage::Revising, "revise for checks"));
1863 }
1864
1865 #[test]
Two limits, real invoices, trust that grows by itself, sales signals1866 fn catching_up_reruns_the_checks_but_not_the_review() {
1867 // The merge moved the head, so the checks are waited for again.
1868 let merged_in = Facts {
1869 review: reviewed(Some(Verdict::Approve)),
Fast pages, required checks on the branch, self-hosted runners, honest incidents1870 workflows: checks(&[], &["CI"]),
Two limits, real invoices, trust that grows by itself, sales signals1871 ..facts()
1872 };
1873 assert_eq!(outcome(merged_in), (Stage::Checking, "wait"));
1874 }
1875
1876 #[test]
1877 fn a_step_under_way_is_not_started_again() {
1878 for (step, stage) in [
1879 ("review", Stage::Reviewing),
1880 ("revision", Stage::Revising),
1881 ("catch_up", Stage::CatchingUp),
1882 ] {
1883 let busy = Facts {
1884 working_on: Some(step.to_owned()),
1885 // Whatever else is true, the step in hand comes first.
1886 check_status: Some(CheckStatus::Failed),
1887 ..facts()
1888 };
1889 assert_eq!(outcome(busy), (stage, "wait"));
1890 }
1891 let asked = Facts {
1892 review_pending: true,
1893 ..facts()
1894 };
1895 assert_eq!(outcome(asked), (Stage::Reviewing, "wait"));
1896 }
1897
1898 #[test]
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1899 fn a_low_confidence_change_waits_for_a_person_instead_of_merging() {
1900 let held = || Facts {
1901 review: reviewed(Some(Verdict::Approve)),
1902 auto_merge: true,
1903 low_confidence: Some("tests not added, 3 revisions".to_owned()),
1904 ..facts()
1905 };
1906 let (lifecycle, next) = decide(held());
1907 assert_eq!(lifecycle.stage, Stage::NeedsYou);
1908 assert!(matches!(next, Next::Wait), "auto-merge must not land it");
1909 assert_eq!(
1910 lifecycle.detail,
1911 "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."
1912 );
1913 // Without auto-merge it needs someone too, and says why.
1914 assert_eq!(outcome(Facts { auto_merge: false, ..held() }), (Stage::NeedsYou, "wait"));
1915 // Not held (the setting is off, or a person approved): it lands.
1916 assert_eq!(outcome(Facts { low_confidence: None, ..held() }), (Stage::Ready, "merge"));
1917 // It holds only a change that is otherwise ready: what comes first,
1918 // such as failed checks, is still dealt with first.
1919 let failing = Facts {
1920 check_status: Some(CheckStatus::Failed),
1921 ..held()
1922 };
1923 assert_eq!(outcome(failing), (Stage::Revising, "revise for checks"));
1924 let unapproved = Facts {
1925 approvals_missing: Some("This repository requires 1 approving review.".to_owned()),
1926 ..held()
1927 };
1928 assert_eq!(decide(unapproved).0.detail, "This repository requires 1 approving review.");
1929 }
1930
1931 #[test]
Two limits, real invoices, trust that grows by itself, sales signals1932 fn once_stopped_it_stays_stopped() {
1933 let (lifecycle, next) = decide(Facts {
1934 stalled: Some("The agent could not catch up.".to_owned()),
1935 review: reviewed(Some(Verdict::Approve)),
1936 behind: true,
1937 ..facts()
1938 });
1939 assert_eq!(lifecycle.stage, Stage::NeedsYou);
1940 assert_eq!(lifecycle.detail, "The agent could not catch up.");
1941 assert!(matches!(next, Next::Wait));
1942 }
1943}

This file's history is long; its oldest lines are credited to the oldest commit read.