Skip to content

Commit

The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were.

Events stops publishing agent.asked, review.completed and memory.changed, drops confidence from pull events and no longer queues events for the context hub or workspace agents. Webhooks, Actions, repos and packages drop the checks that existed only for g1t's agent, while still showing past work by @g1t as before; a run's token is still held to its run. A "g1t" bypass in a ruleset now means g1t itself, not agents. No tables are dropped.

syntaqxcommitted Parent7651c4bBrowse files
35 files+412−70933 files not counted0/35 viewed
+1−1
310310 if below && approvals < rule.required_approvals.max(1) {
311311 let rated = match facts.confidence {
312312 Some(level) => format!("g1t rates this agent's change {} confidence", level.as_str()),
313− None => "g1t has not rated this agent's change yet".to_owned(),
313+ None => "This is an agent's change and has no confidence rating".to_owned(),
314314 };
315315 vec![Problem::new(
316316 format!(
+6−5
175175 fn names(entry: &BypassActor, who: &ActorFacts, workspace: &str) -> bool {
176176 let value = entry.value.trim();
177177 match entry.kind {
178− // g1t's agents act through run tokens (`Agent`), and g1t on its own
179− // as `System`.
180− ActorKind::G1t => who.kind.is_agent(),
178+ // g1t on its own, as `System`: the merge queue, security updates,
179+ // a merge its settings make. An agent acting through a token is
180+ // named only by that token.
181+ ActorKind::G1t => who.kind == Who::G1t,
181182 ActorKind::Token => {
182183 (value.eq_ignore_ascii_case("workspace") && who.kind == Who::Token)
183184 || who.token_id.as_deref().is_some_and(|id| !value.is_empty() && id == value)
415416 }
416417
417418 #[test]
418− fn agents_bypass_only_when_g1t_or_their_token_is_listed() {
419+ fn agents_bypass_only_when_their_token_is_listed() {
419420 let agent = ActorFacts {
420421 username: "g1t".into(),
421422 kind: Who::Agent,
426427 };
427428 let admins = [entry(ActorKind::Role, "admin", BypassMode::Always), entry(ActorKind::Role, "owner", BypassMode::Always)];
428429 assert_eq!(bypass(&admins, &agent, "acme"), None, "an agent does not take its person's role");
429− assert_eq!(bypass(&[entry(ActorKind::G1t, "", BypassMode::Always)], &agent, "acme"), Some(BypassMode::Always));
430+ assert_eq!(bypass(&[entry(ActorKind::G1t, "", BypassMode::Always)], &agent, "acme"), None, "g1t names g1t itself, not agents");
430431 assert_eq!(bypass(&[entry(ActorKind::Token, "tok_1", BypassMode::Always)], &agent, "acme"), Some(BypassMode::Always));
431432 assert_eq!(bypass(&[entry(ActorKind::Token, "tok_2", BypassMode::Always)], &agent, "acme"), None);
432433 let system = ActorFacts { kind: Who::G1t, ..ActorFacts::default() };
+1−4
195195
196196 /// The repository at `path`, when `actor` may do `capability` in it:
197197 /// not found when they cannot read it, forbidden when their role falls
198− /// short. Agents never may: people and tokens run and change workflows.
198+ /// short.
199199 async fn may(&self, actor: &User, path: &RepoPath, capability: Capability) -> Result<Outcome<Repo>> {
200− if actor.kind == PrincipalKind::Agent {
201− return Ok(fail(FailureCode::Forbidden, "An agent cannot do that. Ask a person."));
202− }
203200 let Some(repo) = self.visible_repo(path, &Some(actor.clone())).await? else {
204201 return Ok(fail(FailureCode::NotFound, "There is no such repository."));
205202 };
+1−2
2323 EnvironmentsArgs, MAX_ENVIRONMENT_REVIEWERS, MAX_WAIT_MINUTES, PendingDeployment, PendingDeploymentsArgs, ReviewDeploymentsArgs,
2424 RunActionArgs, RunApproval, SetActionsSettingsArgs, SetEnvironmentArgs, WorkflowRun,
2525 };
26−use g1t_contracts::identity::AGENT_ID;
2726 use g1t_contracts::repos::{Repo, RepoPath};
2827 use g1t_contracts::time::rfc3339;
2928 use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer};
10801079 /// repository's policy, whoever the pull request is for (its owner).
10811080 pub(crate) async fn approval_needed(&self, repo: &Repo, owner: &User, ws: &User) -> Result<Option<String>> {
10821081 // g1t's own work, nobody asked for, is the workspace's.
1083− if owner.id == AGENT_ID || owner.kind == PrincipalKind::System {
1082+ if owner.kind == PrincipalKind::System {
10841083 return Ok(None);
10851084 }
10861085 let policy = self.approval_policy(&repo.id).await?;
+2−5
236236 /// Where `owner` points, if `actor` may see it (`manage` false) or
237237 /// change it (`manage` true): a workspace's runners are seen by its
238238 /// members and changed by its owners; a repository's own, by its
239− /// admins. Agents and workspace tokens never change them, so a
240− /// workflow's `G1T_TOKEN` cannot add a machine to run its own jobs.
239+ /// admins. Workspace tokens never change them, so a workflow's
240+ /// `G1T_TOKEN` cannot add a machine to run its own jobs.
241241 async fn runner_place(&self, actor: &User, owner: &RunnersOwner, manage: bool) -> Result<Outcome<Place>> {
242− if actor.kind == PrincipalKind::Agent {
243− return Ok(fail(FailureCode::Forbidden, "Agents cannot see or change self-hosted runners."));
244− }
245242 if manage && actor.kind == PrincipalKind::Workspace {
246243 return Ok(fail(
247244 FailureCode::Forbidden,
+0−3
227227
228228 impl Actions {
229229 async fn place(&self, actor: &User, owner: &SettingsOwner, changing: bool) -> Result<Outcome<Place>> {
230− if actor.kind == PrincipalKind::Agent {
231− return Ok(fail(FailureCode::Forbidden, "Agents cannot read or change secrets and variables."));
232− }
233230 // A workspace's tokens, G1T_TOKEN among them, read the names but
234231 // never change them: a workflow must not rewrite what it runs with.
235232 if changing && actor.kind == PrincipalKind::Workspace {
+2−3
128128 }
129129
130130 /// Whether whoever a pull request is for is trusted without asking
131−/// identity: g1t's agent in work nobody asked it for, or someone whose
132−/// role here is known to allow pushing.
131+/// identity: someone whose role here is known to allow pushing.
133132 fn trusted_outright(owner: &User, repo: &Repo) -> bool {
134− owner.id == AGENT_ID || access::can(Some(owner), repo, Capability::Push)
133+ access::can(Some(owner), repo, Capability::Push)
135134 }
136135
137136 impl Actions {
+14−63
99 //!
1010 //! | Event | Who | Reason | Severity |
1111 //! | --- | --- | --- | --- |
12−//! | `agent.asked`, `pull.stalled` | the pull request's owner, and its issue's owner and assignees | agent | warning |
12+//! | `pull.stalled` | the pull request's owner, and its issue's owner and assignees | agent | warning |
1313 //! | `pull.review_requested` | the reviewers asked, and the people each team asked tells | review_requested | warning |
1414 //! | `issue.assigned`, `pull.assigned` | the people newly assigned | assign | info |
1515 //! | `checks.completed`, failed or errored | the pull request's owner | ci_activity | error |
1616 //! | `workflow.completed`, failed | the pull request's owner, or whoever pushed | ci_activity | error |
1717 //! | `deployment.failed` | the pull request's owner, or whoever pushed; watchers | ci_activity | error |
1818 //! | `deployment.succeeded` after a failure | the same | ci_activity | success |
19−//! | `review.completed` by g1t | the pull request's owner | author | success, or info for changes asked |
20−//! | `pull.ready` for a change g1t made | whoever asked g1t for it | author | success |
2119 //! | `pull.merged`, `pull.closed`, `issue.closed`, `issue.reopened` | everyone subscribed | state_change | success for a merge, else info |
2220 //! | `comment.created` | everyone mentioned, the people of teams mentioned, then everyone subscribed | mention, team_mention, or why they are subscribed | info (success for an approval) |
2321 //! | `comment.created` by a workspace's agent | the same, shown as "Margo (agent)"; a review also tells the owner, "(advisory)" | the same, or author | the same |
133131 })
134132 };
135133 match event.kind.as_str() {
136− "agent.asked" | "pull.stalled" | "pull.merged" | "pull.closed" | "pull.ready" | "pull.opened"
134+ "pull.stalled" | "pull.merged" | "pull.closed" | "pull.opened"
137135 | "pull.review_requested" | "pull.assigned" | "issue.opened" | "issue.closed" | "issue.reopened"
138136 | "issue.assigned" => on(Some(number("number")?), None),
139137 "checks.completed" => match data["status"].as_str() {
140138 Some("failed" | "errored") => on(Some(number("number")?), None),
141− _ => None,
142− },
143− "review.completed" => match data["verdict"].as_str() {
144− Some("approve" | "request_changes") => on(Some(number("number")?), None),
145139 _ => None,
146140 },
147141 "workflow.completed" => match data["conclusion"].as_str() {
407401
408402 /// Who is told of `event`, in `repo` (`owner/name`), given what it names
409403 /// and who follows it. `actor` is who caused it; outcomes nobody chose
410−/// (checks, workflows, deployments, a review, an agent finishing or
411−/// stopping) are told whoever caused them.
404+/// (checks, workflows, deployments, g1t stopping) are told whoever caused them.
412405 pub fn notices(event: &Event, repo: &str, actor: &Actor, subject: Option<&InboxSubject>, audience: &Audience) -> Vec<Notice> {
413406 let nobody = Actor::default();
414407 let data = &event.data;
421414 event.kind.as_str(),
422415 "checks.completed"
423416 | "workflow.completed"
424− | "review.completed"
425− | "pull.ready"
426− | "agent.asked"
427417 | "pull.stalled"
428418 | "deployment.failed"
429419 | "deployment.succeeded"
439429 let who = actor.name().unwrap_or("g1t");
440430
441431 match (event.kind.as_str(), subject) {
442− ("agent.asked" | "pull.stalled", Some(pull)) => {
443− let (title, body) = if event.kind == "agent.asked" {
444− (format!("An agent is waiting on {at}"), pull.title.clone())
445− } else {
446− let detail = data["detail"].as_str().map(str::trim).filter(|detail| !detail.is_empty());
447− (format!("g1t stopped on {at} and needs you"), detail.unwrap_or(&pull.title).to_owned())
448− };
432+ ("pull.stalled", Some(pull)) => {
433+ let detail = data["detail"].as_str().map(str::trim).filter(|detail| !detail.is_empty());
434+ let (title, body) = (format!("g1t stopped on {at} and needs you"), detail.unwrap_or(&pull.title).to_owned());
449435 told.tell_person(pull.owner(), Reason::Agent, Severity::Warning, &title, &body, true);
450436 if let Some(issue) = &pull.issue {
451437 told.tell_person(issue.owner(), Reason::Agent, Severity::Warning, &title, &body, true);
569555 }
570556 told.tell_watchers("deployments", severity, &title, &body);
571557 }
572− ("review.completed", Some(pull)) => {
573− let (title, severity) = match data["verdict"].as_str() {
574− Some("approve") => (format!("g1t approved {at}"), Severity::Success),
575− _ => (format!("g1t asked for changes on {at}"), Severity::Info),
576− };
577− told.tell_person(pull.owner(), Reason::Author, severity, &title, &pull.title, true);
578− }
579− ("pull.ready", Some(pull)) => {
580− // A change g1t made is ready: the agent's run is over.
581− if let Some(owner) = pull.requested_by.as_ref().filter(|_| is_g1t_id(&pull.author.id) || is_g1t(&pull.author.username)) {
582− let title = format!("g1t finished {at}");
583− told.tell_person(owner, Reason::Author, Severity::Success, &title, &pull.title, true);
584− }
585− }
586558 ("pull.merged" | "pull.closed" | "issue.closed" | "issue.reopened", Some(on)) => {
587559 let (verb, severity) = match event.kind.as_str() {
588560 "pull.merged" => ("merged", Severity::Success),
14451417 /// from where it links, and the item's title and first line.
14461418 pub fn live_notification(event_id: &str, notice: &Notice, url: &str, at: &str) -> serde_json::Value {
14471419 let kind = match notice.reason {
1448− Reason::Agent => "agent_waiting",
14491420 Reason::ReviewRequested => "approval",
14501421 Reason::Mention | Reason::TeamMention => "mention",
14511422 // A critical or high alert, a refused push, a report or a bypass to
21702141 Some(Wanted { repo_id: "rep_1".into(), number: Some(4), comment_id: None })
21712142 );
21722143 assert_eq!(asked("checks.completed", json!({ "repoId": "rep_1", "number": 4, "status": "passed" })), None);
2173− assert_eq!(asked("review.completed", json!({ "repoId": "rep_1", "number": 4 })), None);
2144+ assert_eq!(asked("review.completed", json!({ "repoId": "rep_1", "number": 4, "verdict": "approve" })), None);
2145+ assert_eq!(asked("agent.asked", json!({ "repoId": "rep_1", "number": 4 })), None);
21742146 assert_eq!(asked("workflow.completed", json!({ "repoId": "rep_1", "conclusion": "success" })), None);
21752147 assert_eq!(
21762148 asked("workflow.completed", json!({ "repoId": "rep_1", "conclusion": "failure" })),
21882160 }
21892161
21902162 #[test]
2191− fn a_waiting_or_stopped_agent_needs_the_pull_requests_and_the_issues_people_first() {
2192− let asked = event("agent.asked", Some("usr_agent"), json!({ "number": 7 }));
2193− let notices_ = notices(&asked, "acme/rocket", &Actor::default(), Some(&pull()), &nobody());
2163+ fn a_stopped_pull_request_needs_its_people_and_its_issues_people_first() {
2164+ let stalled = event("pull.stalled", None, json!({ "number": 7 }));
2165+ let notices_ = notices(&stalled, "acme/rocket", &Actor::default(), Some(&pull()), &nobody());
21942166 assert_eq!(
21952167 told(&notices_),
21962168 vec![
21992171 ("dee", Reason::Agent, Severity::Warning),
22002172 ]
22012173 );
2202− assert_eq!(notices_[0].title, "An agent is waiting on acme/rocket#7");
2174+ assert_eq!(notices_[0].title, "g1t stopped on acme/rocket#7 and needs you");
22032175 assert_eq!(notices_[0].body, "Add the inbox");
22042176 // Stopping says why; even someone who unsubscribed hears of it.
22052177 let stalled = event("pull.stalled", None, json!({ "number": 7, "detail": "Its checks could not be run." }));
26342606 let notices_ = notices(&created, "acme/rocket", &actor("usr_bo", "bo"), Some(&on), &audience);
26352607 // cy was mentioned, which is asked of them; ana unsubscribed from the conversation.
26362608 assert_eq!(told(&notices_), vec![("cy", Reason::Mention, Severity::Info)]);
2637− }
2638−
2639− #[test]
2640− fn g1t_finishing_or_reviewing_tells_the_person_it_worked_for() {
2641− // The agent acts as the person it works for: still an outcome they hear of.
2642− let ready = event("pull.ready", Some("usr_ana"), json!({ "number": 7 }));
2643− let notices_ = notices(&ready, "acme/rocket", &actor("usr_ana", "ana"), Some(&g1t_pull()), &nobody());
2644− assert_eq!(told(&notices_), vec![("ana", Reason::Author, Severity::Success)]);
2645− assert_eq!(notices_[0].title, "g1t finished acme/rocket#7");
2646− // A person's draft marked ready is not news to them.
2647− assert!(notices(&ready, "acme/rocket", &actor("usr_ana", "ana"), Some(&pull()), &nobody()).is_empty());
2648−
2649− let approve = event("review.completed", None, json!({ "number": 7, "verdict": "approve" }));
2650− assert_eq!(
2651− told(&notices(&approve, "acme/rocket", &Actor::default(), Some(&pull()), &nobody())),
2652− vec![("ana", Reason::Author, Severity::Success)]
2653− );
2654− let changes = event("review.completed", None, json!({ "number": 7, "verdict": "request_changes" }));
2655− assert_eq!(
2656− told(&notices(&changes, "acme/rocket", &Actor::default(), Some(&pull()), &nobody())),
2657− vec![("ana", Reason::Author, Severity::Info)]
2658− );
26592609 }
26602610
26612611 #[test]
28082758 };
28092759 let live = live_notification("evt_1", &notice, "/Acme/api/pull/4#c1", "2026-10-08T00:00:00Z");
28102760 assert_eq!(live["id"], "inbox:evt_1");
2811− assert_eq!(live["kind"], "agent_waiting");
2761+ // A stop that needs a person is an ordinary inbox item.
2762+ assert_eq!(live["kind"], "inbox");
28122763 assert_eq!(live["workspace"], "acme");
28132764 assert_eq!(live["href"], "/Acme/api/pull/4#c1");
28142765 assert_eq!(live["actor"]["name"], "acme/api");
+1−4
3333 { "binding": "SUBSCRIBER_REPOS", "queue": "g1t-events-repos" },
3434 { "binding": "SUBSCRIBER_BILLING", "queue": "g1t-events-billing" },
3535 { "binding": "SUBSCRIBER_SECURITY", "queue": "g1t-events-security" },
36− { "binding": "SUBSCRIBER_CONTEXT", "queue": "g1t-events-context" },
3736 { "binding": "SUBSCRIBER_SEARCH", "queue": "g1t-events-search" },
3837 { "binding": "SUBSCRIBER_PACKAGES", "queue": "g1t-events-packages" },
3938 // Pages that cite code a merge changed, and projects' docs folders
4039 // (services/artifacts/src/staleness.ts). Create it once:
4140 // npx wrangler queues create g1t-events-docs
42− { "binding": "SUBSCRIBER_DOCS", "queue": "g1t-events-docs" },
43− // Agents' routines that run on events (services/agents/src/triggers.ts).
44− { "binding": "SUBSCRIBER_AGENTS", "queue": "g1t-events-agents" }
41+ { "binding": "SUBSCRIBER_DOCS", "queue": "g1t-events-docs" }
4542 ],
4643 "consumers": [{ "queue": "g1t-events", "max_batch_size": 100, "max_batch_timeout": 1, "max_retries": 3, "dead_letter_queue": "g1t-events-dlq" }]
4744 },
+11−10
1717 //! selection; a workspace's own token is a member with Write unless an
1818 //! owner gave it Admin. A workflow job's token reaches a package only from
1919 //! the repository it is linked to (Write) or from a repository listed under
20−//! the package's Manage Actions access, with that role. An agent's run
21−//! token may push only where its run may push code. A deploy key never
20+//! the package's Manage Actions access, with that role. A sandbox run's
21+//! token (checks, the merge queue, a deployment) may push only where its
22+//! run may push code. A deploy key never
2223 //! reaches packages.
2324
2425 use g1t_contracts::access::{self, RepoRef, RepoRole};
186187 }
187188
188189 /// `user`'s role on the package, and the rule it comes from. A person (or
189−/// an agent's run, as the person it works for) only; tokens are checked
190+/// a sandbox run, as the person it works for) only; tokens are checked
190191 /// before.
191192 pub fn role_of(user: &User, target: &Target<'_>) -> (Option<PackageRole>, &'static str) {
192193 let public = target.is_public().then_some(PackageRole::Read);
292293 };
293294 };
294295
295− // An agent's run token: only where its run may read or push code, and
296+ // A sandbox run's token: only where its run may read or push code, and
296297 // then as the person it works for.
297− if user.kind == PrincipalKind::Agent {
298+ if user.kind == PrincipalKind::Agent || user.acting.is_some() {
298299 let Some(scope) = user.acting.as_ref().map(|acting| acting.scope.clone()) else {
299− return Decision::deny("agent", "This agent token cannot use packages.");
300+ return Decision::deny("agent", "This run's token cannot use packages.");
300301 };
301302 if action.administers() {
302− return Decision::deny("agent", "A g1t agent cannot delete packages or change their settings.");
303+ return Decision::deny("agent", "A run's token cannot delete packages or change their settings.");
303304 }
304305 let Some(repo) = target.repo else {
305− return Decision::deny("agent", "A g1t agent can use only the packages of the repository it works on.");
306+ return Decision::deny("agent", "A run's token can use only the packages of the repository it runs in.");
306307 };
307308 let path = RepoPath { namespace: target.workspace.to_owned(), name: repo.name.to_owned() };
308309 let decision = credentials::decide_git(&scope, &path, action == Action::Push);
311312 }
312313 match credentials::as_person(&user) {
313314 Some(person) => user = person,
314− None => return Decision::deny("agent", "This agent token cannot use packages."),
315+ None => return Decision::deny("agent", "This run's token cannot use packages."),
315316 }
316317 }
317318
722723 repo: path("web"),
723724 operations: vec![],
724725 run: Some(RunBinding {
725− kind: RunCredentialKind::Implement,
726+ kind: RunCredentialKind::Checks,
726727 usage: CredentialUse::Runner,
727728 run_id: None,
728729 number: None,
+2−2
14531453 Some(_) => not_found(),
14541454 None => Outcome::fail(FailureCode::Unauthenticated, "Authentication required."),
14551455 };
1456− // An agent's token works through the API only: its sandbox has its
1456+ // A run's token works through the API only: its sandbox has its
14571457 // own way to push, to its own pull request.
14581458 if a.viewer.as_ref().is_some_and(|user| user.kind == PrincipalKind::Agent) {
14591459 return Ok(Outcome::fail(
14601460 FailureCode::Forbidden,
1461− "A g1t agent's token cannot be used with git.",
1461+ "A run's token cannot be used with git this way.",
14621462 ));
14631463 }
14641464 if let (true, Some(user)) = (write, &a.viewer)
+3−5
144144 entry: None,
145145 });
146146 };
147− let run_scope = user
148− .acting
149− .as_ref()
150− .filter(|_| user.kind == PrincipalKind::Agent)
151− .map(|acting| acting.scope.clone());
147+ // A run credential carries its scope; whatever kind of principal
148+ // it resolves as, it is held to that scope.
149+ let run_scope = user.acting.as_ref().map(|acting| acting.scope.clone());
152150 let entry = |target: AuditTarget, decision: &Decision| {
153151 NewAuditEntry::new(
154152 AuditActor::of(&user),
+1−4
212212 /// and the repository when they are a repository's. A repository's
213213 /// need the Admin role on it, to see as to change, since they carry
214214 /// its events out; the workspace's are its members' to see and its
215− /// owners' to change. An agent's token changes neither.
215+ /// owners' to change.
216216 async fn allowed(&self, viewer: &Viewer, owner: &HookOwner, managing: bool) -> Result<Outcome<Option<Repo>>> {
217217 let Some(user) = viewer.as_ref() else {
218218 return Ok(fail(FailureCode::Forbidden, "Sign in to see webhooks."));
219219 };
220− if managing && user.kind == PrincipalKind::Agent {
221− return Ok(fail(FailureCode::Forbidden, "An agent cannot manage webhooks."));
222− }
223220 if owner.repo.is_some() {
224221 let Some(repo) = self.repository(owner, viewer).await? else {
225222 return Ok(fail(FailureCode::NotFound, "There is no such repository in this workspace."));
+0−305
1−//! A workspace's own agents (Margo, @margo) commenting on issues and pull
2−//! requests, and reviewing pull requests, as themselves, on behalf of a
3−//! person whose access caps them. The agents service calls these; nothing
4−//! reaches them with a person's token.
5−//!
6−//! What an agent writes is stored with the agent as its author and the
7−//! person it acted for beside it (migrations/0033_agent_comments.sql). Its
8−//! review is advisory: the verdict is kept in `agent_verdict`, never in
9−//! `verdict`, which every rule about approvals reads (required approvals
10−//! in mergeability.rs and rulesets.rs through the `Verdicts` prefetch,
11−//! a person's request for changes in lifecycle.rs `person_request`,
12−//! confidence.rs, contributions.rs), and code owners skip it by name
13−//! (codeowners.rs `latest_verdicts`). So it never satisfies a required
14−//! approval or a code owner, and never blocks a merge.
15−
16−use g1t_contracts::credentials::Principal;
17−use g1t_contracts::events::CommentCreated;
18−use g1t_contracts::time::rfc3339;
19−use g1t_contracts::work::*;
20−use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, new_id};
21−use g1t_kit::now_ms;
22−use serde::Deserialize;
23−use worker::Result;
24−use worker::wasm_bindgen::JsValue;
25−
26−use crate::retired::writable;
27−use crate::{MAX_ENTRY_CHARS, UNVERIFIED, Work};
28−
29−/// What an agent may write where, before anything is read: why not, or none.
30−fn refusal(agent: &AgentRef, body: &str, verdict: Option<AgentVerdict>) -> Option<&'static str> {
31− if agent.id.trim().is_empty() || agent.handle.trim().is_empty() {
32− return Some("Name the agent: its id and handle.");
33− }
34− // An approval speaks for itself; anything else has to say something.
35− if body.is_empty() && verdict != Some(AgentVerdict::Approve) {
36− return Some("A comment cannot be empty.");
37− }
38− if body.chars().count() > MAX_ENTRY_CHARS {
39− return Some("That comment is too long.");
40− }
41− None
42−}
43−
44−/// Why an agent may not review a pull request as it stands, or none.
45−fn review_refusal(status: PullStatus) -> Option<&'static str> {
46− match status {
47− PullStatus::Open => None,
48− PullStatus::Draft => Some("A draft is still being worked on; an agent reviews it once it is ready for review."),
49− PullStatus::Merged | PullStatus::Closed => Some("Only an open pull request can be reviewed."),
50− }
51−}
52−
53−/// Whether an agent that wrote `written` times on one issue or pull request
54−/// in the last hour may write again.
55−fn within_limit(written: u32) -> bool {
56− written < AGENT_COMMENTS_PER_WINDOW
57−}
58−
59−#[derive(Deserialize)]
60−struct Count {
61− n: u32,
62−}
63−
64−impl Work {
65− /// `workspace_agent_comment`.
66− pub(crate) async fn workspace_agent_comment(&self, a: WorkspaceAgentCommentArgs) -> Result<Outcome<Comment>> {
67− self.agent_writes(a.repo, a.number, a.agent, a.acting_for, a.body, None).await
68− }
69−
70− /// `workspace_agent_review`.
71− pub(crate) async fn workspace_agent_review(&self, a: WorkspaceAgentReviewArgs) -> Result<Outcome<Comment>> {
72− self.agent_writes(a.repo, a.number, a.agent, a.acting_for, a.body, Some(a.verdict)).await
73− }
74−
75− /// A comment, or with `verdict` a review, by `agent` as itself on
76− /// behalf of `acting_for`, with the checks a person's comment has.
77− async fn agent_writes(
78− &self,
79− path: g1t_contracts::repos::RepoPath,
80− number: u32,
81− agent: AgentRef,
82− acting_for: User,
83− body: String,
84− verdict: Option<AgentVerdict>,
85− ) -> Result<Outcome<Comment>> {
86− let body = body.trim();
87− if let Some(refused) = refusal(&agent, body, verdict) {
88− return Ok(Outcome::fail(FailureCode::Invalid, refused));
89− }
90− // The person it acts for, as if they wrote it themselves.
91− if !acting_for.verified {
92− return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
93− }
94− let repo = match self.repo(&path, &Some(acting_for.clone())).await? {
95− Outcome::Ok(repo) => repo,
96− Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
97− };
98− if let Outcome::Fail(failure) = writable(&repo) {
99− return Ok(Outcome::Fail(failure));
100− }
101− let mut pull_id = None;
102− let table = if self.issue(&repo.id, number).await?.is_some() {
103− if verdict.is_some() {
104− return Ok(Outcome::fail(FailureCode::Invalid, "Only a pull request can be reviewed."));
105− }
106− "issues"
107− } else if let Some(pull) = self.pull(&repo.id, number).await? {
108− if verdict.is_some()
109− && let Some(refused) = review_refusal(pull.status)
110− {
111− return Ok(Outcome::fail(FailureCode::Conflict, refused));
112− }
113− // A command to g1t on a dependency update it opened is a
114− // person's to give, never an agent's.
115− if pull.author.is_system() && g1t_contracts::updates::update_command(body).is_some() {
116− return Ok(Outcome::fail(
117− FailureCode::Forbidden,
118− "An agent cannot give commands on g1t's dependency updates; ask a person with the Write role.",
119− ));
120− }
121− pull_id = Some(pull.id.clone());
122− "pulls"
123− } else {
124− return Ok(Outcome::fail(FailureCode::NotFound, "No issue or pull request has that number."));
125− };
126−
127− let now = now_ms();
128− let written = self
129− .db
130− .prepare(
131− "SELECT count(*) AS n FROM comments
132− WHERE agent_id = ? AND repo_id = ? AND number = ? AND created_at >= ?",
133− )
134− .bind(&[
135− agent.id.as_str().into(),
136− repo.id.as_str().into(),
137− number.into(),
138− rfc3339(now.saturating_sub(AGENT_COMMENT_WINDOW_MS)).into(),
139− ])?
140− .first::<Count>(None)
141− .await?
142− .map_or(0, |count| count.n);
143− if !within_limit(written) {
144− return Ok(Outcome::fail(
145− FailureCode::Conflict,
146− format!(
147− "@{} has written {AGENT_COMMENTS_PER_WINDOW} times on #{number} in the last hour; it can write again later.",
148− agent.handle
149− ),
150− ));
151− }
152−
153− let comment = Comment {
154− id: new_id("cmt", now),
155− kind: CommentKind::Comment,
156− author: User {
157− id: agent.id.clone(),
158− username: agent.handle.clone(),
159− kind: PrincipalKind::Agent,
160− ..User::default()
161− },
162− body: body.to_owned(),
163− path: None,
164− line: None,
165− verdict: verdict.and_then(AgentVerdict::verdict),
166− created_at: rfc3339(now),
167− edited_at: None,
168− acting_for: Some(User {
169− id: acting_for.id.clone(),
170− username: acting_for.username.clone(),
171− ..User::default()
172− }),
173− advisory: verdict.is_some(),
174− agent: Some(agent.clone()),
175− };
176− self.db
177− .batch(vec![
178− self.db
179− .prepare(
180− "INSERT INTO comments
181− (id, repo_id, number, author_id, author_name, body, created_at,
182− agent_id, agent_handle, agent_name, agent_avatar_seed,
183− acting_for_id, acting_for_name, agent_verdict)
184− VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
185− )
186− .bind(&[
187− comment.id.as_str().into(),
188− repo.id.as_str().into(),
189− number.into(),
190− agent.id.as_str().into(),
191− agent.handle.as_str().into(),
192− body.into(),
193− comment.created_at.as_str().into(),
194− agent.id.as_str().into(),
195− agent.handle.as_str().into(),
196− agent.display_name.as_str().into(),
197− agent.avatar_seed.as_str().into(),
198− acting_for.id.as_str().into(),
199− acting_for.username.as_str().into(),
200− verdict.map_or(JsValue::NULL, |verdict| verdict.as_str().into()),
201− ])?,
202− self.db
203− .prepare(format!("UPDATE {table} SET updated_at = ? WHERE repo_id = ? AND number = ?"))
204− .bind(&[comment.created_at.as_str().into(), repo.id.as_str().into(), number.into()])?,
205− ])
206− .await?;
207− // What an agent says never summons g1t (mentions.rs `may_summon`),
208− // so no mention is recorded. The event is the person's doing, as
209− // their own comment's would be, with the agent named on it.
210− self.publish(
211− "comment.created",
212− &repo.id,
213− &acting_for,
214− CommentCreated {
215− comment_id: comment.id.clone(),
216− repo_id: repo.id.clone(),
217− number,
218− pull_id,
219− verdict: comment.verdict,
220− agent: Some(agent),
221− acting_for: Some(Principal::from(&acting_for)),
222− advisory: comment.advisory,
223− },
224− )
225− .await?;
226− Ok(Outcome::Ok(comment))
227− }
228−}
229−
230−#[cfg(test)]
231−mod tests {
232− use super::*;
233−
234− fn margo() -> AgentRef {
235− AgentRef {
236− id: "agt_1".into(),
237− handle: "margo".into(),
238− display_name: "Margo".into(),
239− avatar_seed: "margo".into(),
240− }
241− }
242−
243− #[test]
244− fn an_agent_says_something_unless_it_approves() {
245− assert_eq!(refusal(&margo(), "", None), Some("A comment cannot be empty."));
246− assert_eq!(refusal(&margo(), "", Some(AgentVerdict::Comment)), Some("A comment cannot be empty."));
247− assert_eq!(refusal(&margo(), "", Some(AgentVerdict::RequestChanges)), Some("A comment cannot be empty."));
248− assert_eq!(refusal(&margo(), "", Some(AgentVerdict::Approve)), None);
249− assert_eq!(refusal(&margo(), "Looks right.", None), None);
250− let nobody = AgentRef { handle: String::new(), ..margo() };
251− assert!(refusal(&nobody, "hi", None).is_some());
252− }
253−
254− #[test]
255− fn an_agent_reviews_only_what_is_ready() {
256− assert_eq!(review_refusal(PullStatus::Open), None);
257− assert!(review_refusal(PullStatus::Draft).is_some());
258− assert!(review_refusal(PullStatus::Merged).is_some());
259− assert!(review_refusal(PullStatus::Closed).is_some());
260− }
261−
262− #[test]
263− fn five_an_hour_on_one_pull_request() {
264− assert!(within_limit(0));
265− assert!(within_limit(4));
266− assert!(!within_limit(5));
267− }
268−
269− #[test]
270− fn the_wire_reads_snake_case_and_comments_read_camel_case() {
271− let asked: WorkspaceAgentReviewArgs = serde_json::from_value(serde_json::json!({
272− "repo": { "namespace": "acme", "name": "web" },
273− "number": 7,
274− "agent": { "id": "agt_1", "handle": "margo", "display_name": "Margo", "avatar_seed": "margo" },
275− "acting_for": { "id": "usr_1", "username": "ana" },
276− "verdict": "request_changes",
277− "body": "Trim the name."
278− }))
279− .unwrap();
280− assert_eq!(asked.verdict, AgentVerdict::RequestChanges);
281− assert_eq!(asked.agent, margo());
282− let shown = serde_json::to_value(&asked.agent).unwrap();
283− assert_eq!(shown["displayName"], "Margo");
284− assert_eq!(shown["avatarSeed"], "margo");
285− }
286−
287− #[test]
288− fn an_agent_review_shows_its_verdict_but_is_advisory() {
289− let row: crate::rows::CommentRow = serde_json::from_value(serde_json::json!({
290− "id": "cmt_1", "kind": "comment", "author_id": "agt_1", "author_name": "margo",
291− "body": "Ship it.", "path": null, "line": null, "verdict": null, "created_at": "2026-10-09T00:00:00Z",
292− "agent_id": "agt_1", "agent_handle": "margo", "agent_name": "Margo", "agent_avatar_seed": "m",
293− "acting_for_id": "usr_1", "acting_for_name": "ana", "agent_verdict": "approve"
294− }))
295− .unwrap();
296− assert_eq!(row.answerable_id(), "usr_1");
297− assert!(row.has_verdict());
298− let comment = Comment::from(row);
299− assert_eq!(comment.verdict, Some(Verdict::Approve));
300− assert!(comment.advisory);
301− assert_eq!(comment.author.kind, PrincipalKind::Agent);
302− assert_eq!(comment.answerable().username, "ana");
303− assert_eq!(comment.agent.unwrap().display_name, "Margo");
304− }
305−}
+0−1041
A large diff (1,041 lines) is not shown until you ask for it.
+0−337
1−//! What the runner needs to apply a workspace's plan caps (see
2−//! `@g1t/contracts` `compute.ts`): how many agents it has at work, what an
3−//! issue's agents have spent, a comment from g1t when it cannot
4−//! start, and somewhere for runs to wait for a free slot.
5−//!
6−//! The decisions are the runner's; this keeps the counts and the queue.
7−
8−use g1t_contracts::agents::*;
9−use g1t_contracts::events::CommentCreated;
10−use g1t_contracts::repos::RepoPath;
11−use g1t_contracts::time::rfc3339;
12−use g1t_contracts::{FailureCode, Outcome, User, new_id};
13−use g1t_kit::now_ms;
14−use serde::Deserialize;
15−use worker::Result;
16−use worker::wasm_bindgen::JsValue;
17−
18−use crate::Work;
19−use crate::checks::hash;
20−use crate::reviews::{AGENT_ID, AGENT_NAME};
21−use crate::rows::{NumberRow, ValueRow};
22−use crate::runs::member_of;
23−
24−/// The longest comment g1t leaves for a run it could not start.
25−const MAX_COMMENT_CHARS: usize = 2000;
26−/// The most runs one workspace may have waiting at once.
27−const MAX_WAITING: u32 = 50;
28−
29−#[derive(Deserialize)]
30−struct SpendRow {
31− spent: Option<f64>,
32−}
33−
34−#[derive(Deserialize)]
35−struct PullKey {
36− id: String,
37− issue_id: Option<String>,
38−}
39−
40−#[derive(Deserialize)]
41−struct WaitRow {
42− id: String,
43− workspace: String,
44− kind: String,
45− payload: String,
46− created_at: String,
47−}
48−
49−#[derive(Deserialize)]
50−struct CostRow {
51− cost_usd: Option<f64>,
52− token_hash: String,
53−}
54−
55−/// The kinds that count against the agents-at-once cap, as SQL.
56−fn agent_kinds_sql() -> String {
57− AGENT_KINDS
58− .iter()
59− .map(|kind| format!("'{}'", kind.as_str()))
60− .collect::<Vec<_>>()
61− .join(", ")
62−}
63−
64−/// Dollars as millionths, rounded up.
65−pub(crate) fn to_micros(usd: f64) -> i64 {
66− if usd.is_finite() && usd > 0.0 {
67− (usd * 1_000_000.0).ceil() as i64
68− } else {
69− 0
70− }
71−}
72−
73−impl Work {
74− async fn repo_id_of(&self, path: &RepoPath) -> Result<Outcome<String>> {
75− let service = User {
76− id: "svc_runner".to_owned(),
77− username: "g1t".to_owned(),
78− ..User::default()
79− };
80− Ok(match self.repo(path, &member_of(&service, &path.namespace)).await? {
81− Outcome::Ok(repo) => Outcome::Ok(repo.id),
82− Outcome::Fail(failure) => Outcome::Fail(failure),
83− })
84− }
85−
86− pub(crate) async fn active_agents(&self, a: ActiveAgentsArgs) -> Result<u32> {
87− // A run whose sandbox died unnoticed does not hold a slot forever.
88− self.sweep_silent().await?;
89− let count = self
90− .db
91− .prepare(format!(
92− "SELECT count(*) AS n FROM agent_runs
93− WHERE workspace = ? AND status IN ('queued', 'running') AND kind IN ({})",
94− agent_kinds_sql()
95− ))
96− .bind(&[a.workspace.to_lowercase().into()])?
97− .first::<NumberRow>(None)
98− .await?;
99− Ok(count.map_or(0, |row| row.n))
100− }
101−
102− pub(crate) async fn issue_spend(&self, a: IssueSpendArgs) -> Result<Outcome<IssueSpend>> {
103− let repo_id = match self.repo_id_of(&a.repo).await? {
104− Outcome::Ok(id) => id,
105− Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
106− };
107− let pull = self
108− .db
109− .prepare("SELECT id, issue_id FROM pulls WHERE repo_id = ? AND number = ?")
110− .bind(&[repo_id.as_str().into(), a.number.into()])?
111− .first::<PullKey>(None)
112− .await?;
113− // The issue a pull request is for, or the pull request itself.
114− let issue_id = match &pull {
115− Some(pull) => pull.issue_id.clone(),
116− None => self
117− .db
118− .prepare("SELECT id AS value FROM issues WHERE repo_id = ? AND number = ?")
119− .bind(&[repo_id.as_str().into(), a.number.into()])?
120− .first::<ValueRow>(None)
121− .await?
122− .map(|row| row.value),
123− };
124− let (issue_number, spent) = match (&issue_id, &pull) {
125− (Some(issue_id), _) => {
126− let number = self
127− .db
128− .prepare("SELECT number AS n FROM issues WHERE id = ?")
129− .bind(&[issue_id.as_str().into()])?
130− .first::<NumberRow>(None)
131− .await?
132− .map_or(a.number, |row| row.n);
133− let spent = self
134− .db
135− .prepare(
136− "SELECT sum(cost_usd) AS spent FROM agent_runs
137− WHERE pull_id IN (SELECT id FROM pulls WHERE issue_id = ?1)
138− OR (repo_id = ?2 AND number = ?3 AND pull_id IS NULL)",
139− )
140− .bind(&[issue_id.as_str().into(), repo_id.as_str().into(), number.into()])?
141− .first::<SpendRow>(None)
142− .await?;
143− (number, spent)
144− }
145− (None, Some(pull)) => {
146− let spent = self
147− .db
148− .prepare(
149− "SELECT sum(cost_usd) AS spent FROM agent_runs
150− WHERE pull_id = ?1 OR (repo_id = ?2 AND number = ?3 AND pull_id IS NULL)",
151− )
152− .bind(&[pull.id.as_str().into(), repo_id.as_str().into(), a.number.into()])?
153− .first::<SpendRow>(None)
154− .await?;
155− (a.number, spent)
156− }
157− (None, None) => {
158− return Ok(Outcome::fail(FailureCode::NotFound, "No issue or pull request by that number."));
159− }
160− };
161− Ok(Outcome::Ok(IssueSpend {
162− issue: issue_number,
163− spent_micros: to_micros(spent.and_then(|row| row.spent).unwrap_or(0.0)),
164− }))
165− }
166−
167− pub(crate) async fn wait_for_slot(&self, a: WaitForSlotArgs) -> Result<bool> {
168− // The claim is given back as if it had never been taken, so the
169− // revision it counted does not count.
170− let released = self
171− .db
172− .prepare(
173− "UPDATE pulls SET
174− revisions = CASE WHEN working_on = 'revision' AND revisions > 0 THEN revisions - 1 ELSE revisions END,
175− working_on = NULL, working_until = NULL, stage_detail = ?
176− WHERE id = ? AND status = 'open'
177− RETURNING id AS value",
178− )
179− .bind(&[a.reason.trim().into(), a.pull_id.as_str().into()])?
180− .first::<ValueRow>(None)
181− .await?;
182− Ok(released.is_some())
183− }
184−
185− pub(crate) async fn agent_comment(&self, a: AgentCommentArgs) -> Result<bool> {
186− let body: String = a.body.trim().chars().take(MAX_COMMENT_CHARS).collect();
187− if body.is_empty() {
188− return Ok(false);
189− }
190− let repo_id = match self.repo_id_of(&a.repo).await? {
191− Outcome::Ok(id) => id,
192− Outcome::Fail(_) => return Ok(false),
193− };
194− let pull_id = self
195− .db
196− .prepare("SELECT id AS value FROM pulls WHERE repo_id = ? AND number = ?")
197− .bind(&[repo_id.as_str().into(), a.number.into()])?
198− .first::<ValueRow>(None)
199− .await?
200− .map(|row| row.value);
201− let table = if pull_id.is_some() { "pulls" } else { "issues" };
202− let now = now_ms();
203− let id = new_id("cmt", now);
204− let at = rfc3339(now);
205− self.db
206− .batch(vec![
207− self.db
208− .prepare(
209− "INSERT INTO comments (id, repo_id, number, author_id, author_name, body, created_at)
210− VALUES (?, ?, ?, ?, ?, ?, ?)",
211− )
212− .bind(&[
213− id.as_str().into(),
214− repo_id.as_str().into(),
215− a.number.into(),
216− AGENT_ID.into(),
217− AGENT_NAME.into(),
218− body.as_str().into(),
219− at.as_str().into(),
220− ])?,
221− self.db
222− .prepare(format!("UPDATE {table} SET updated_at = ? WHERE repo_id = ? AND number = ?"))
223− .bind(&[at.as_str().into(), repo_id.as_str().into(), a.number.into()])?,
224− ])
225− .await?;
226− self.publish_as(
227− "comment.created",
228− &repo_id,
229− Some(AGENT_ID.to_owned()),
230− CommentCreated {
231− comment_id: id,
232− repo_id: repo_id.clone(),
233− number: a.number,
234− pull_id,
235− verdict: None,
236− ..CommentCreated::default()
237− },
238− )
239− .await?;
240− Ok(true)
241− }
242−
243− pub(crate) async fn add_wait(&self, a: AddWaitArgs) -> Result<Outcome<bool>> {
244− let workspace = a.workspace.to_lowercase();
245− let waiting = self
246− .db
247− .prepare("SELECT count(*) AS n FROM agent_waits WHERE workspace = ?")
248− .bind(&[workspace.as_str().into()])?
249− .first::<NumberRow>(None)
250− .await?
251− .map_or(0, |row| row.n);
252− if waiting >= MAX_WAITING {
253− return Ok(Outcome::fail(
254− FailureCode::Conflict,
255− format!("{MAX_WAITING} runs are already waiting for this workspace's agents. Try again when some have started."),
256− ));
257− }
258− let now = now_ms();
259− self.db
260− .prepare("INSERT INTO agent_waits (id, workspace, kind, payload, created_at) VALUES (?, ?, ?, ?, ?)")
261− .bind(&[
262− new_id("wait", now).into(),
263− workspace.into(),
264− a.kind.into(),
265− serde_json::to_string(&a.payload)?.into(),
266− rfc3339(now).into(),
267− ])?
268− .run()
269− .await?;
270− Ok(Outcome::Ok(true))
271− }
272−
273− pub(crate) async fn waiting_workspaces(&self, _a: WaitingWorkspacesArgs) -> Result<Vec<String>> {
274− let rows = self
275− .db
276− .prepare(
277− "SELECT workspace AS value FROM agent_waits
278− GROUP BY workspace ORDER BY min(created_at) LIMIT 100",
279− )
280− .all()
281− .await?
282− .results::<ValueRow>()?;
283− Ok(rows.into_iter().map(|row| row.value).collect())
284− }
285−
286− pub(crate) async fn take_wait(&self, a: TakeWaitArgs) -> Result<Option<AgentWait>> {
287− let row = self
288− .db
289− .prepare(
290− "DELETE FROM agent_waits WHERE id = (
291− SELECT id FROM agent_waits WHERE workspace = ? ORDER BY created_at, id LIMIT 1)
292− RETURNING id, workspace, kind, payload, created_at",
293− )
294− .bind(&[a.workspace.to_lowercase().into()])?
295− .first::<WaitRow>(None)
296− .await?;
297− Ok(row.map(|row| AgentWait {
298− id: row.id,
299− workspace: row.workspace,
300− kind: row.kind,
301− payload: serde_json::from_str(&row.payload).unwrap_or(serde_json::Value::Null),
302− created_at: row.created_at,
303− }))
304− }
305−
306− pub(crate) async fn run_cost(&self, a: RunCostArgs) -> Result<Option<f64>> {
307− let row = self
308− .db
309− .prepare("SELECT cost_usd, token_hash FROM agent_runs WHERE id = ?")
310− .bind(&[JsValue::from(a.run_id.as_str())])?
311− .first::<CostRow>(None)
312− .await?;
313− Ok(row
314− .filter(|row| row.token_hash == hash(&a.token))
315− .and_then(|row| row.cost_usd))
316− }
317−}
318−
319−#[cfg(test)]
320−mod tests {
321− use super::*;
322−
323− #[test]
324− fn dollars_become_micros_rounded_up() {
325− assert_eq!(to_micros(0.094), 94_000);
326− assert_eq!(to_micros(0.0000001), 1);
327− assert_eq!(to_micros(-1.0), 0);
328− assert_eq!(to_micros(f64::NAN), 0);
329− }
330−
331− #[test]
332− fn only_agent_kinds_hold_a_slot() {
333− let sql = agent_kinds_sql();
334− assert!(sql.contains("'implement'") && sql.contains("'plan'"));
335− assert!(!sql.contains("'checks'") && !sql.contains("'queue'") && !sql.contains("'mergecheck'"));
336− }
337−}
+0−993
Loading the diff…
+30−27
Loading the diff…
+78−1
Loading the diff…
+0−5
Loading the diff…
+53−176
Loading the diff…
+135−1221
A large diff (1,632 lines) is not shown until you ask for it.
+26−204
Loading the diff…
+0−1006
A large diff (1,006 lines) is not shown until you ask for it.
+1−2
Loading the diff…
+0−533
Loading the diff…
+0−709
Loading the diff…
+4−101
Loading the diff…
+3−3
Loading the diff…
+12−51
Loading the diff…
+20−247
Loading the diff…
+5−20
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…