Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
2,953 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.

Inbox: the events service tells people what needs them as events arrive1//! The work service: issues, pull requests, comments and sessions.
2//!
3//! Other services reach it over `POST /rpc/<method>`; see
4//! `g1t_contracts::work` for the methods and their arguments. It also
5//! consumes its queue of events from the bus.
6
7mod authored;
8mod checks;
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)9mod ghost;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar10mod codeowners;
Merge checks: statuses and check runs on every commit11mod commit_checks;
Chat controls, public profiles, shadcn selects, and no Docs tab in a project12mod contributions;
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'13mod coordination;
Merge branch 'worktree-agent-ab8033ab973f8d99a'14mod decisions;
Inbox: the events service tells people what needs them as events arrive15mod guardrails;
16mod inbox;
Merge branch 'worktree-agent-ab8033ab973f8d99a'17mod integration;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar18mod labels;
Inbox: the events service tells people what needs them as events arrive19mod lifecycle;
20mod mergeability;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar21mod milestones;
Inbox: the events service tells people what needs them as events arrive22mod prefetch;
23mod queue;
24mod retired;
25mod reviews;
26mod rows;
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge27mod rulesets;
Inbox: the events service tells people what needs them as events arrive28mod runs;
29mod settings;
30mod statuses;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar31mod team_reviews;
Inbox: the events service tells people what needs them as events arrive32
33use g1t_contracts::events::{
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts34 ChangedFrom, CommentChanges, CommentCreated, CommentDeleted, CommentEdited, DeletedComment, Event, IssueEvent,
35 NewEvent, Publish, PullEvent, SessionAppended,
Inbox: the events service tells people what needs them as events arrive36};
37use g1t_contracts::identity::UsernameArgs;
38use g1t_contracts::repos::{
39 ForkArgs, GetArgs, HeadArgs, LandArgs, Landed, NeedsAgentReason, PullBranchUpdate, ReadableArgs, Repo, RepoPath,
40 UpdatePullBranchArgs,
41};
42use g1t_contracts::access::{self, Capability, Denied};
43use g1t_contracts::time::rfc3339;
44use g1t_contracts::work::*;
45use futures_util::future::{try_join, try_join3, try_join_all};
46use g1t_contracts::{FailureCode, Outcome, User, Viewer, new_id};
47use g1t_kit::{args, now_ms, reply, rpc_method};
48use serde::Serialize;
49use worker::wasm_bindgen::JsValue;
50use worker::{
51 Context, D1Database, Env, Fetcher, MessageBatch, MessageExt, Request, Response, Result, event,
52};
53
54use retired::writable;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar55use rows::{CommentRow, IssueRow, MovedRow, NumberRow, PULL_COLUMNS, PullRow, SessionRow, Snapshot};
Inbox: the events service tells people what needs them as events arrive56
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge57pub(crate) const SOURCE: &str = "work";
Inbox: the events service tells people what needs them as events arrive58const MAX_ENTRY_BATCH: usize = 200;
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar59pub(crate) const MAX_ENTRY_CHARS: usize = 64_000;
Inbox: the events service tells people what needs them as events arrive60const MAX_TITLE_CHARS: usize = 200;
61const SESSION_PAGE: u32 = 500;
62const LIST_PAGE: u32 = 100;
63const MAX_ASSIGNEES: usize = 10;
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge64pub(crate) const UNVERIFIED: &str = "Confirm your email address first. Check your inbox, or resend the link from the banner on g1t.sh.";
Inbox: the events service tells people what needs them as events arrive65
66const ISSUE_COLUMNS: &str = "issues.*,
67 (SELECT count(*) FROM pulls WHERE pulls.issue_id = issues.id) AS pull_count,
68 (SELECT agent FROM pulls
69 WHERE pulls.issue_id = issues.id AND pulls.status IN ('draft', 'open')
70 AND pulls.fork_repo_id IS NOT NULL
71 ORDER BY pulls.number DESC LIMIT 1) AS agent,
72 (SELECT count(*) FROM comments
73 WHERE comments.repo_id = issues.repo_id AND comments.number = issues.number
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar74 AND comments.kind = 'comment') AS comment_count,
75 (SELECT title FROM milestones
76 WHERE milestones.repo_id = issues.repo_id AND milestones.number = issues.milestone) AS milestone_title";
Inbox: the events service tells people what needs them as events arrive77
78fn no_issue<T>() -> Outcome<T> {
79 Outcome::fail(FailureCode::NotFound, "Issue not found.")
80}
81
82fn no_pull<T>() -> Outcome<T> {
83 Outcome::fail(FailureCode::NotFound, "Pull request not found.")
84}
85
86/// Whether a pull request was behind when its mergeability was last
87/// worked out, and for which pair of commits (mergeability.rs).
88#[derive(serde::Deserialize)]
89struct StoredBehind {
90 #[serde(default)]
91 behind: Option<u8>,
92 #[serde(default)]
93 mergeable_key: Option<String>,
94}
95
96impl StoredBehind {
97 /// The stored answer, if it was worked out for `head`.
98 fn for_head(&self, head: Option<&str>) -> Option<bool> {
99 let (worked_for, _) = self.mergeable_key.as_deref()?.split_once("..")?;
100 (Some(worked_for) == head).then_some(self.behind? != 0)
101 }
102}
103
104/// Refuses `actor` unless their role on `repo` has `capability`: not found
105/// when they cannot read it, forbidden with the role it needs otherwise.
106pub(crate) fn allowed(actor: Option<&User>, repo: &Repo, capability: Capability) -> Outcome<()> {
107 match access::check(actor, repo, capability) {
108 Ok(()) => Outcome::Ok(()),
109 Err(Denied::NotFound) => Outcome::fail(FailureCode::NotFound, "Repository not found."),
110 Err(Denied::Forbidden) => Outcome::fail(
111 FailureCode::Forbidden,
112 access::needs(capability, &format!("{}/{}", repo.namespace, repo.name)),
113 ),
114 }
115}
116
117fn optional(value: &Option<String>) -> JsValue {
118 value.as_deref().map_or(JsValue::NULL, JsValue::from)
119}
120
121fn optional_number(value: Option<u32>) -> JsValue {
122 value.map_or(JsValue::NULL, JsValue::from)
123}
124
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar125/// Names the branch a pull request merges into when it is the default
126/// branch, which is stored as none so that it follows a change of default.
127pub(crate) fn fill_base(pull: &mut Pull, repo: &Repo) {
128 if pull.base.as_deref().is_none_or(str::is_empty) {
129 pull.base = Some(repo.default_branch.clone());
130 }
131}
132
133/// The branch a pull request is stored as merging into: none for the
134/// default branch.
135fn stored_base(base: &str, repo: &Repo) -> Option<String> {
136 let base = base.trim();
137 (!base.is_empty() && base != repo.default_branch).then(|| base.to_owned())
138}
139
Inbox: the events service tells people what needs them as events arrive140/// The lowercase name a `State` is stored and sent as.
141fn state_name(state: Option<State>) -> Option<&'static str> {
142 state.map(|state| match state {
143 State::Open => "open",
144 State::Closed => "closed",
145 })
146}
147
148/// A trimmed title, or why it cannot be used.
149fn valid_title(title: &str) -> std::result::Result<&str, &'static str> {
150 let title = title.trim();
151 if title.is_empty() {
152 Err("A title is required.")
153 } else if title.chars().count() > MAX_TITLE_CHARS {
154 Err("That title is too long.")
155 } else {
156 Ok(title)
157 }
158}
159
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts160/// What a closed pull request was when it was closed.
161#[derive(serde::Deserialize)]
162struct ClosedFrom {
163 #[serde(default)]
164 closed_from: Option<String>,
165}
166
167/// Why a pull request in `status` cannot be reopened, if it cannot: only a
168/// closed one can, never a merged one.
169fn reopen_refusal(status: PullStatus) -> Option<&'static str> {
170 match status {
171 PullStatus::Closed => None,
172 PullStatus::Merged => Some("This pull request was merged; it cannot be reopened."),
173 PullStatus::Draft | PullStatus::Open => Some("This pull request is already open."),
174 }
175}
176
177/// What a closed pull request is reopened as: the draft it was, when it
178/// was closed as one, else ready for review.
179fn reopened_status(closed_from: Option<&str>) -> PullStatus {
180 if closed_from == Some("draft") { PullStatus::Draft } else { PullStatus::Open }
181}
182
183/// Why a pull request in `status` cannot be turned into a draft, if it
184/// cannot: only one that is open, ready for review, can.
185fn draft_refusal(status: PullStatus) -> Option<&'static str> {
186 match status {
187 PullStatus::Open => None,
188 PullStatus::Draft => Some("This pull request is already a draft."),
189 PullStatus::Merged => Some("This pull request is already merged."),
190 PullStatus::Closed => Some("This pull request is closed. Reopen it first."),
191 }
192}
193
Inbox: the events service tells people what needs them as events arrive194/// Unwraps an `Outcome`, returning its failure from the enclosing method.
195macro_rules! check {
196 ($outcome:expr) => {
197 match $outcome {
198 Outcome::Ok(value) => value,
199 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
200 }
201 };
202}
203
204struct Work {
205 db: D1Database,
206 identity: Fetcher,
207 repos: Fetcher,
208 events: Fetcher,
209 /// GitHub Actions: runs a merge queue's `merge_group` workflows.
210 actions: Fetcher,
211 /// Where this request's time went, for its `Server-Timing`.
212 timing: g1t_kit::d1::Timing,
213 /// A pull request's rows read in one batch for this request
214 /// (prefetch.rs), which the helpers below read instead of the database.
215 prefetched: std::cell::RefCell<Option<std::rc::Rc<prefetch::Prefetched>>>,
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge216 /// Repositories read in this request, by id, for rules that need one
217 /// where only a pull request is at hand (rulesets.rs `repo_for`).
218 known_repos: std::cell::RefCell<std::collections::HashMap<String, Repo>>,
Inbox: the events service tells people what needs them as events arrive219}
220
221impl Work {
222 async fn publish<T: Serialize>(
223 &self,
224 kind: &'static str,
225 repo_id: &str,
226 actor: &User,
227 data: T,
228 ) -> Result<()> {
Merge branch 'worktree-agent-a3abfcce648e87dca'229 // What a workflow job's token did is marked, so it starts no
230 // workflows (`g1t_contracts::events::CAUSED_BY_JOB`).
231 self.publish_as(kind, repo_id, Some(actor.id.clone()), g1t_contracts::events::marked(data, Some(actor)))
Inbox: the events service tells people what needs them as events arrive232 .await
233 }
234
235 /// A pull request's owner (whoever asked g1t for it, or its author) as
236 /// a viewer who can read its repository and source. Stored people carry
237 /// no memberships, so a private repository would otherwise look missing
238 /// to them. The membership given reads and nothing more: it is for
239 /// looking, never for acting.
240 pub(crate) async fn owner_viewer(&self, pull: &Pull) -> Result<Viewer> {
241 let path: Option<RepoPath> = g1t_kit::call(
242 &self.repos,
243 "path_by_id",
244 &g1t_contracts::repos::PathByIdArgs { id: pull.repo_id.clone() },
245 )
246 .await?;
247 let mut owner = pull.owner().clone();
248 if let Some(path) = path
249 && !owner.is_member(&path.namespace.to_lowercase())
250 {
251 owner.workspaces.push(g1t_contracts::Membership {
252 base_permission: Some(access::BasePermission::Read),
253 ..g1t_contracts::Membership::member(path.namespace.to_lowercase())
254 });
255 }
256 Ok(Some(owner))
257 }
258
259 /// Publishes an event caused by `actor`, or by g1t itself.
260 async fn publish_as<T: Serialize>(
261 &self,
262 kind: &'static str,
263 repo_id: &str,
264 actor: Option<String>,
265 data: T,
266 ) -> Result<()> {
267 let event = NewEvent {
268 kind,
269 source: SOURCE,
270 repo_id: Some(repo_id.to_owned()),
271 actor,
272 data,
273 };
274 g1t_kit::call(
275 &self.events,
276 "publish",
277 &Publish {
278 events: vec![event],
279 },
280 )
281 .await
282 }
283
284 /// The repository, if the viewer may see it. Whether they may is
285 /// decided by the repos service.
286 async fn repo(&self, path: &RepoPath, viewer: &Viewer) -> Result<Outcome<Repo>> {
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge287 let found: Outcome<Repo> = self
288 .timing
Inbox: the events service tells people what needs them as events arrive289 .rpc(g1t_kit::call(
290 &self.repos,
291 "get",
292 &GetArgs {
293 path: path.clone(),
294 viewer: viewer.clone(),
295 },
296 ))
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge297 .await?;
298 if let Outcome::Ok(repo) = &found {
299 self.known_repos.borrow_mut().insert(repo.id.clone(), repo.clone());
300 }
301 Ok(found)
Inbox: the events service tells people what needs them as events arrive302 }
303
304 /// The next number in the repository's sequence. Taking it is one
305 /// statement, so concurrent opens cannot be given the same number.
306 async fn next_number(&self, repo_id: &str) -> Result<u32> {
307 let row = self
308 .db
309 .prepare(
310 "INSERT INTO counters (repo_id, last) VALUES (?, 1)
311 ON CONFLICT (repo_id) DO UPDATE SET last = last + 1
312 RETURNING last AS n",
313 )
314 .bind(&[repo_id.into()])?
315 .first::<NumberRow>(None)
316 .await?;
317 row.map(|row| row.n)
318 .ok_or_else(|| worker::Error::RustError("no number was assigned".into()))
319 }
320
321 async fn issue(&self, repo_id: &str, number: u32) -> Result<Option<Issue>> {
322 Ok(self
323 .db
324 .prepare(format!(
325 "SELECT {ISSUE_COLUMNS} FROM issues WHERE repo_id = ? AND number = ?"
326 ))
327 .bind(&[repo_id.into(), number.into()])?
328 .first::<IssueRow>(None)
329 .await?
330 .map(Issue::from))
331 }
332
333 async fn pull(&self, repo_id: &str, number: u32) -> Result<Option<Pull>> {
334 Ok(self
335 .db
336 .prepare(format!("SELECT {PULL_COLUMNS} FROM pulls WHERE repo_id = ? AND number = ?"))
337 .bind(&[repo_id.into(), number.into()])?
338 .first::<PullRow>(None)
339 .await?
340 .map(Pull::from))
341 }
342
343 /// The repository and one of its issues, as seen by `viewer`.
344 async fn issue_at(
345 &self,
346 path: &RepoPath,
347 number: u32,
348 viewer: &Viewer,
349 ) -> Result<Outcome<(Repo, Issue)>> {
350 let Outcome::Ok(repo) = self.repo(path, viewer).await? else {
351 return Ok(no_issue());
352 };
353 Ok(match self.issue(&repo.id, number).await? {
354 Some(issue) => Outcome::Ok((repo, issue)),
355 None => no_issue(),
356 })
357 }
358
359 /// The repository and one of its pull requests, as seen by `viewer`.
360 async fn pull_at(
361 &self,
362 path: &RepoPath,
363 number: u32,
364 viewer: &Viewer,
365 ) -> Result<Outcome<(Repo, Pull)>> {
366 let Outcome::Ok(repo) = self.repo(path, viewer).await? else {
367 return Ok(no_pull());
368 };
369 Ok(match self.pull(&repo.id, number).await? {
370 Some(pull) => Outcome::Ok((repo, pull)),
371 None => no_pull(),
372 })
373 }
374
375 /// Records something that happened to an issue or a pull request, so
376 /// that it shows in the conversation where it happened. `text` is what
377 /// `author` did, as the rest of a sentence starting with their name.
378 pub(crate) async fn note(
379 &self,
380 repo_id: &str,
381 number: u32,
382 author: (&str, &str),
383 text: &str,
384 ) -> Result<()> {
385 let now = now_ms();
386 self.db
387 .prepare(
388 "INSERT INTO comments
389 (id, repo_id, number, author_id, author_name, body, kind, created_at)
390 VALUES (?, ?, ?, ?, ?, ?, 'event', ?)",
391 )
392 .bind(&[
393 new_id("cmt", now).into(),
394 repo_id.into(),
395 number.into(),
396 author.0.into(),
397 author.1.into(),
398 text.into(),
399 rfc3339(now).into(),
400 ])?
401 .run()
402 .await?;
403 Ok(())
404 }
405
406 /// Notes who was added to and removed from a list of people, such as
407 /// "assigned ana" or "requested a review from g1t".
408 async fn note_changes(
409 &self,
410 repo_id: &str,
411 number: u32,
412 actor: &User,
413 before: &[String],
414 after: &[String],
415 (added, removed): (&str, &str),
416 ) -> Result<()> {
417 let joined = |names: Vec<&String>| {
418 names
419 .into_iter()
420 .map(String::as_str)
421 .collect::<Vec<_>>()
422 .join(", ")
423 };
424 let new: Vec<&String> = after.iter().filter(|name| !before.contains(name)).collect();
425 let gone: Vec<&String> = before.iter().filter(|name| !after.contains(name)).collect();
426 let who = (actor.id.as_str(), actor.username.as_str());
427 if !new.is_empty() {
428 // Taking something on oneself reads better said that way.
429 let text = if added == "assigned" && new == [&actor.username] {
430 "self-assigned this".to_owned()
431 } else {
432 format!("{added} {}", joined(new))
433 };
434 self.note(repo_id, number, who, &text).await?;
435 }
436 if !gone.is_empty() {
437 self.note(repo_id, number, who, &format!("{removed} {}", joined(gone)))
438 .await?;
439 }
440 Ok(())
441 }
442
443 fn issue_event(issue: &Issue) -> IssueEvent {
444 IssueEvent {
445 issue_id: issue.id.clone(),
446 repo_id: issue.repo_id.clone(),
447 number: issue.number,
448 author: Some((&issue.author).into()),
449 requested_by: issue.requested_by.as_ref().map(Into::into),
450 ..IssueEvent::default()
451 }
452 }
453
454 /// The commit a pull request's change is at in git right now: its
455 /// fork's default branch, or its branch.
456 async fn live_head(&self, pull: &Pull) -> Result<Option<String>> {
457 g1t_kit::call(
458 &self.repos,
459 "head",
460 &HeadArgs {
461 repo_id: pull.fork_repo_id.clone().unwrap_or_else(|| pull.repo_id.clone()),
462 branch: pull.branch.clone().unwrap_or_default(),
463 },
464 )
465 .await
466 }
467
468 fn pull_event(pull: &Pull) -> PullEvent {
469 PullEvent {
470 pull_id: pull.id.clone(),
471 repo_id: pull.repo_id.clone(),
472 number: pull.number,
473 author: Some((&pull.author).into()),
474 requested_by: pull.requested_by.as_ref().map(Into::into),
475 issue: pull.issue,
476 ..PullEvent::default()
477 }
478 }
479
480 // --- Issues ------------------------------------------------------------
481
482 async fn open_issue(&self, a: OpenIssueArgs) -> Result<Outcome<Issue>> {
483 if !a.actor.verified {
484 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
485 }
486 let title = match valid_title(&a.title) {
487 Ok(title) => title,
488 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
489 };
490 let Some(labels) = normalize_labels(&a.labels) else {
491 return Ok(Outcome::fail(
492 FailureCode::Invalid,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar493 format!("An issue can have up to {MAX_LABELS} labels of up to {MAX_LABEL_CHARS} characters each."),
Inbox: the events service tells people what needs them as events arrive494 ));
495 };
496 let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?);
497 check!(writable(&repo));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar498 // Labels it does not have yet are made for someone who may triage;
499 // anyone else picks from those there are.
500 let colors = check!(self.ensure_labels(&a.actor, &repo, &labels).await?);
501 let milestone = match a.milestone.filter(|number| *number > 0) {
502 Some(number) => {
503 check!(allowed(Some(&a.actor), &repo, Capability::Triage));
504 check!(self.milestone_ref(&repo.id, number).await?)
505 }
506 None => None,
507 };
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.508 // Commands given the old way are words now: added to the body
509 // under "Definition of done". What has to pass to merge is
Inbox: the events service tells people what needs them as events arrive510 // the branch's required checks.
511 let body = with_definition_of_done(&a.body, &commands_pass(&a.checks));
512
513 let now = now_ms();
514 let id = new_id("iss", now);
515 let number = self.next_number(&repo.id).await?;
516 let timestamp = rfc3339(now);
517 let (author, requested_by) = authorship(&a.actor, false);
518 self.db
519 .prepare(
520 "INSERT INTO issues
521 (id, repo_id, number, title, body, labels, checks, author_id, author_name,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar522 requested_by_id, requested_by_name, created_at, updated_at, milestone)
523 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
Inbox: the events service tells people what needs them as events arrive524 )
525 .bind(&[
526 id.as_str().into(),
527 repo.id.as_str().into(),
528 number.into(),
529 title.into(),
530 body.into(),
531 serde_json::to_string(&labels)?.into(),
532 "[]".into(),
533 author.id.as_str().into(),
534 author.username.as_str().into(),
535 optional(&requested_by.as_ref().map(|user| user.id.clone())),
536 optional(&requested_by.as_ref().map(|user| user.username.clone())),
537 timestamp.as_str().into(),
538 timestamp.as_str().into(),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar539 optional_number(milestone.as_ref().map(|milestone| milestone.number)),
Inbox: the events service tells people what needs them as events arrive540 ])?
541 .run()
542 .await?;
543 let Some(issue) = self.issue(&repo.id, number).await? else {
544 return Ok(no_issue());
545 };
546 self.publish(
547 "issue.opened",
548 &repo.id,
549 &a.actor,
550 IssueEvent {
551 title: Some(issue.title.clone()),
552 ..Self::issue_event(&issue)
553 },
554 )
555 .await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar556 // Opened with labels and a milestone: each is said, as it would be
557 // if they were added afterwards, without notes in the conversation.
558 for label in &issue.labels {
559 let color = colors.iter().find(|(name, _)| name == label).map_or_else(|| label_color_for(label), |(_, c)| c.clone());
560 let label = Some(g1t_contracts::events::EventLabel { name: label.clone(), color });
561 self.publish("issue.labeled", &repo.id, &a.actor, IssueEvent { label, ..Self::issue_event(&issue) })
562 .await?;
563 }
564 if let Some(milestone) = milestone {
565 self.publish(
566 "issue.milestoned",
567 &repo.id,
568 &a.actor,
569 IssueEvent { milestone: Some(milestone), ..Self::issue_event(&issue) },
570 )
571 .await?;
572 }
Inbox: the events service tells people what needs them as events arrive573 Ok(Outcome::Ok(issue))
574 }
575
576 async fn list_issues(&self, a: ListIssuesArgs) -> Result<Outcome<Vec<Issue>>> {
577 let state = state_name(a.state);
578 let label = a
579 .label
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar580 .map(|label| label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase())
Inbox: the events service tells people what needs them as events arrive581 .filter(|label| !label.is_empty());
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar582 let milestone = a.milestone;
Inbox: the events service tells people what needs them as events arrive583 let list = |repo_id: String| {
584 let label = label.clone();
585 async move {
586 let state = state.map_or(JsValue::NULL, JsValue::from);
587 let query = self
588 .db
589 .prepare(format!(
590 "SELECT {ISSUE_COLUMNS} FROM issues
591 WHERE repo_id = ? AND (? IS NULL OR state = ?)
592 AND (? IS NULL OR EXISTS
593 (SELECT 1 FROM json_each(issues.labels) WHERE json_each.value = ?))
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar594 AND (? IS NULL OR milestone = ?)
Inbox: the events service tells people what needs them as events arrive595 ORDER BY number DESC LIMIT ?"
596 ))
597 .bind(&[
598 repo_id.into(),
599 state.clone(),
600 state,
601 optional(&label),
602 optional(&label),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar603 optional_number(milestone),
604 optional_number(milestone),
Inbox: the events service tells people what needs them as events arrive605 LIST_PAGE.into(),
606 ])?;
607 self.timing.db(1, query.all()).await?.results::<IssueRow>()
608 }
609 };
610 let (_, rows) = check!(self.repo_then(&a.repo, &a.viewer, list).await?);
611 Ok(Outcome::Ok(rows.into_iter().map(Issue::from).collect()))
612 }
613
614 /// An issue, the pull requests for it and its comments: one batch,
615 /// started beside the access check (prefetch.rs).
616 async fn get_issue(&self, a: ViewArgs) -> Result<Outcome<IssueDetail>> {
617 let number = a.number;
618 let read = |repo_id: String| async move {
619 let key = [JsValue::from(repo_id.as_str()), JsValue::from(number)];
620 let statements = vec![
621 self.db
622 .prepare(format!("SELECT {ISSUE_COLUMNS} FROM issues WHERE repo_id = ?1 AND number = ?2"))
623 .bind(&key)?,
624 self.db
625 .prepare(format!(
626 "SELECT {PULL_COLUMNS} FROM pulls
627 WHERE issue_id = (SELECT id FROM issues WHERE repo_id = ?1 AND number = ?2)
628 ORDER BY number"
629 ))
630 .bind(&key)?,
631 self.db
632 .prepare("SELECT * FROM comments WHERE repo_id = ?1 AND number = ?2 ORDER BY id LIMIT 500")
633 .bind(&key)?,
634 ];
635 let results = self.timing.db(3, self.db.batch(statements)).await?;
636 let rows = |index: usize| results.get(index).ok_or_else(|| worker::Error::RustError("short batch".into()));
637 Ok::<_, worker::Error>((
638 rows(0)?.results::<IssueRow>()?.into_iter().next().map(Issue::from),
639 rows(1)?.results::<PullRow>()?.into_iter().map(Pull::from).collect::<Vec<_>>(),
640 rows(2)?.results::<CommentRow>()?.into_iter().map(Comment::from).collect::<Vec<_>>(),
641 ))
642 };
643 let Outcome::Ok((_, (Some(issue), pulls, comments))) = self.repo_then(&a.repo, &a.viewer, read).await? else {
644 return Ok(no_issue());
645 };
646 Ok(Outcome::Ok(IssueDetail { comments, pulls, issue }))
647 }
648
649 /// The issue, if `actor` wrote it or may triage the repository's issues.
650 async fn manageable_issue(
651 &self,
652 actor: &User,
653 path: &RepoPath,
654 number: u32,
655 ) -> Result<Outcome<Issue>> {
656 let (repo, issue) = check!(self.issue_at(path, number, &Some(actor.clone())).await?);
657 check!(writable(&repo));
658 if issue.owner().id != actor.id {
659 check!(allowed(Some(actor), &repo, Capability::Triage));
660 }
661 Ok(Outcome::Ok(issue))
662 }
663
664 async fn update_issue(&self, a: UpdateIssueArgs) -> Result<Outcome<Issue>> {
665 let issue = check!(self.manageable_issue(&a.actor, &a.repo, a.number).await?);
666 let title = match a.title.as_deref().map(valid_title) {
667 Some(Err(message)) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
668 Some(Ok(title)) => Some(title.to_owned()),
669 None => None,
670 };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar671 if a.labels.as_deref().is_some_and(|labels| normalize_labels(labels).is_none()) {
672 return Ok(Outcome::fail(
673 FailureCode::Invalid,
674 format!("An issue can have up to {MAX_LABELS} labels of up to {MAX_LABEL_CHARS} characters each."),
675 ));
676 }
Inbox: the events service tells people what needs them as events arrive677 let assignees = match a.assignees {
678 Some(names) => Some(check!(self.valid_assignees(names).await?)),
679 None => None,
680 };
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'681 // What it is blocked by: checked before anything changes.
682 let blocked_by = match &a.blocked_by {
683 Some(asked) => Some(check!(self.checked_blocked_by(&issue.repo_id, issue.number, asked).await?)),
684 None => None,
685 };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar686 // Its labels and milestone first: either can be refused, and then
687 // nothing else changes.
688 if a.milestone.is_some() || a.labels.is_some() {
689 let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?);
690 if let Some(number) = a.milestone {
691 check!(self.set_milestone(&a.actor, &repo, &labels::Item::Issue(issue.clone()), number).await?);
692 }
693 if let Some(labels) = &a.labels {
694 check!(self.relabel(&a.actor, &repo, &labels::Item::Issue(issue.clone()), labels).await?);
695 }
696 }
Inbox: the events service tells people what needs them as events arrive697 let assigned = assignees.as_ref().map(serde_json::to_string).transpose()?;
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'698 let blocked = blocked_by.as_ref().map(serde_json::to_string).transpose()?;
Inbox: the events service tells people what needs them as events arrive699 let body = a.body.map(|body| body.trim().to_owned());
700 self.db
701 .prepare(
702 "UPDATE issues
703 SET title = COALESCE(?, title), body = COALESCE(?, body),
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'704 assignees = COALESCE(?, assignees), blocked_by = COALESCE(?, blocked_by),
705 updated_at = ?
Inbox: the events service tells people what needs them as events arrive706 WHERE id = ?",
707 )
708 .bind(&[
709 optional(&title),
710 optional(&body),
711 optional(&assigned),
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'712 optional(&blocked),
Inbox: the events service tells people what needs them as events arrive713 rfc3339(now_ms()).into(),
714 issue.id.as_str().into(),
715 ])?
716 .run()
717 .await?;
718 let before = issue.assignees.clone();
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'719 let blocked_before = issue.blocked_by.clone();
Inbox: the events service tells people what needs them as events arrive720 let Some(issue) = self.issue(&issue.repo_id, issue.number).await? else {
721 return Ok(no_issue());
722 };
723 self.publish(
724 "issue.updated",
725 &issue.repo_id,
726 &a.actor,
727 Self::issue_event(&issue),
728 )
729 .await?;
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'730 if blocked_by.is_some() {
731 self.blocked_by_changed(&issue, &blocked_before, &a.actor).await?;
732 }
Inbox: the events service tells people what needs them as events arrive733 if let Some(assignees) = assignees {
734 self.note_changes(
735 &issue.repo_id,
736 issue.number,
737 &a.actor,
738 &before,
739 &assignees,
740 ("assigned", "unassigned"),
741 )
742 .await?;
Events: review requests, assignments, stops and deployments are published743 let added: Vec<String> = assignees.iter().filter(|name| !before.contains(name)).cloned().collect();
Inbox: the events service tells people what needs them as events arrive744 self.publish(
745 "issue.assigned",
746 &issue.repo_id,
747 &a.actor,
748 IssueEvent {
749 assignees: Some(assignees),
Events: review requests, assignments, stops and deployments are published750 added: Some(added),
Inbox: the events service tells people what needs them as events arrive751 ..Self::issue_event(&issue)
752 },
753 )
754 .await?;
755 }
756 Ok(Outcome::Ok(issue))
757 }
758
759 /// Usernames as given, tidied, if each names an account.
760 async fn valid_assignees(&self, names: Vec<String>) -> Result<Outcome<Vec<String>>> {
761 let mut assignees: Vec<String> = Vec::new();
762 for name in names {
763 let name = name.trim().trim_start_matches('@').to_lowercase();
764 if name.is_empty() || assignees.contains(&name) {
765 continue;
766 }
767 if assignees.len() == MAX_ASSIGNEES {
768 return Ok(Outcome::fail(
769 FailureCode::Invalid,
770 format!("An issue can be assigned to at most {MAX_ASSIGNEES} people."),
771 ));
772 }
773 let account: Viewer = g1t_kit::call(
774 &self.identity,
775 "user_by_username",
776 &UsernameArgs {
777 username: name.clone(),
778 },
779 )
780 .await?;
781 if account.is_none() {
782 return Ok(Outcome::fail(
783 FailureCode::Invalid,
784 format!("There is no account named {name}."),
785 ));
786 }
787 assignees.push(name);
788 }
789 Ok(Outcome::Ok(assignees))
790 }
791
792 /// Open issues assigned to the viewer, in every repository. Callers
793 /// show only those in repositories the viewer can still see.
794 async fn list_assigned_issues(&self, a: ViewerArgs) -> Result<Vec<Issue>> {
795 let Some(viewer) = a.viewer else {
796 return Ok(Vec::new());
797 };
798 let rows = self
799 .db
800 .prepare(format!(
801 "SELECT {ISSUE_COLUMNS} FROM issues
802 WHERE state = 'open' AND EXISTS (
803 SELECT 1 FROM json_each(issues.assignees) WHERE json_each.value = ?)
804 ORDER BY updated_at DESC LIMIT 50"
805 ))
806 .bind(&[viewer.username.into()])?
807 .all()
808 .await?
809 .results::<IssueRow>()?;
810 Ok(rows.into_iter().map(Issue::from).collect())
811 }
812
813 async fn close_issue(&self, a: IssueActionArgs) -> Result<Outcome<Issue>> {
814 let mut issue = check!(self.manageable_issue(&a.actor, &a.repo, a.number).await?);
815 if issue.state == State::Closed {
816 return Ok(Outcome::fail(
817 FailureCode::Conflict,
818 "This issue is already closed.",
819 ));
820 }
821 let reason = a.reason.unwrap_or(IssueReason::Completed);
822 let now = rfc3339(now_ms());
823 self.db
824 .prepare(
825 "UPDATE issues SET state = 'closed', reason = ?, closed_at = ?, updated_at = ?
826 WHERE id = ?",
827 )
828 .bind(&[
829 reason.as_str().into(),
830 now.as_str().into(),
831 now.as_str().into(),
832 issue.id.as_str().into(),
833 ])?
834 .run()
835 .await?;
836 self.publish(
837 "issue.closed",
838 &issue.repo_id,
839 &a.actor,
840 IssueEvent {
841 reason: Some(reason.as_str()),
842 ..Self::issue_event(&issue)
843 },
844 )
845 .await?;
846 self.note(
847 &issue.repo_id,
848 issue.number,
849 (&a.actor.id, &a.actor.username),
850 match reason {
851 IssueReason::Completed => "closed this as completed",
852 IssueReason::NotPlanned => "closed this as not planned",
853 },
854 )
855 .await?;
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'856 // Its claims end, and the issues it blocks hear (coordination/).
857 self.issue_resolved(&issue.repo_id, issue.number, &a.actor, true, None).await;
Inbox: the events service tells people what needs them as events arrive858 issue.state = State::Closed;
859 issue.reason = Some(reason);
860 issue.closed_at = Some(now.clone());
861 issue.updated_at = now;
862 Ok(Outcome::Ok(issue))
863 }
864
865 async fn reopen_issue(&self, a: IssueActionArgs) -> Result<Outcome<Issue>> {
866 let mut issue = check!(self.manageable_issue(&a.actor, &a.repo, a.number).await?);
867 if issue.state == State::Open {
868 return Ok(Outcome::fail(
869 FailureCode::Conflict,
870 "This issue is already open.",
871 ));
872 }
873 let now = rfc3339(now_ms());
874 self.db
875 .prepare(
876 "UPDATE issues
877 SET state = 'open', reason = NULL, resolved_by = NULL, closed_at = NULL,
878 updated_at = ?
879 WHERE id = ?",
880 )
881 .bind(&[now.as_str().into(), issue.id.as_str().into()])?
882 .run()
883 .await?;
884 self.publish(
885 "issue.reopened",
886 &issue.repo_id,
887 &a.actor,
888 Self::issue_event(&issue),
889 )
890 .await?;
891 self.note(
892 &issue.repo_id,
893 issue.number,
894 (&a.actor.id, &a.actor.username),
895 "reopened this",
896 )
897 .await?;
898 issue.state = State::Open;
899 issue.reason = None;
900 issue.resolved_by = None;
901 issue.closed_at = None;
902 issue.updated_at = now;
903 Ok(Outcome::Ok(issue))
904 }
905
906
907 async fn counts(&self, a: ViewArgs) -> Result<Outcome<Counts>> {
908 let read = |repo_id: String| async move {
909 let query = self
910 .db
911 .prepare(
912 "SELECT
913 (SELECT count(*) FROM issues WHERE repo_id = ?1 AND state = 'open') AS issues,
914 (SELECT count(*) FROM pulls
915 WHERE repo_id = ?1 AND status IN ('draft', 'open')) AS pulls",
916 )
917 .bind(&[repo_id.into()])?;
918 self.timing.db(1, query.first::<Counts>(None)).await
919 };
920 let (_, counts) = check!(self.repo_then(&a.repo, &a.viewer, read).await?);
921 Ok(Outcome::Ok(counts.unwrap_or(Counts {
922 issues: 0,
923 pulls: 0,
924 })))
925 }
926
927 // --- Comments ----------------------------------------------------------
928
929 async fn add_comment(&self, a: AddCommentArgs) -> Result<Outcome<Comment>> {
930 if !a.actor.verified {
931 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
932 }
933 let body = a.body.trim();
934 // An approval speaks for itself; anything else has to say something.
935 if body.is_empty() && a.verdict != Some(Verdict::Approve) {
936 return Ok(Outcome::fail(
937 FailureCode::Invalid,
938 "A comment cannot be empty.",
939 ));
940 }
941 let path = a
942 .path
943 .as_deref()
944 .map(str::trim)
945 .filter(|path| !path.is_empty());
946 let line = a.line.filter(|line| *line > 0 && path.is_some());
947 if body.chars().count() > MAX_ENTRY_CHARS {
948 return Ok(Outcome::fail(
949 FailureCode::Invalid,
950 "That comment is too long.",
951 ));
952 }
953 let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?);
954 check!(writable(&repo));
955 // The number names an issue or a pull request, never both.
956 let mut pull_id = None;
Merge branch 'worktree-agent-ab8033ab973f8d99a'957 let mut pull_issue = None;
Inbox: the events service tells people what needs them as events arrive958 let table = if self.issue(&repo.id, a.number).await?.is_some() {
959 if path.is_some() || a.verdict.is_some() {
960 return Ok(Outcome::fail(
961 FailureCode::Invalid,
962 "Only a pull request can be reviewed or commented on by line.",
963 ));
964 }
965 "issues"
966 } else if let Some(pull) = self.pull(&repo.id, a.number).await? {
967 if a.verdict.is_some() && pull.is_owned_by(&a.actor.id) {
968 return Ok(Outcome::fail(
969 FailureCode::Forbidden,
970 "You cannot approve or request changes on your own pull request.",
971 ));
972 }
973 pull_id = Some(pull.id.clone());
Merge branch 'worktree-agent-ab8033ab973f8d99a'974 pull_issue = pull.issue;
Inbox: the events service tells people what needs them as events arrive975 "pulls"
976 } else {
977 return Ok(Outcome::fail(
978 FailureCode::NotFound,
979 "No issue or pull request has that number.",
980 ));
981 };
982
983 let now = now_ms();
984 let comment = Comment {
985 kind: CommentKind::Comment,
986 id: new_id("cmt", now),
987 author: a.actor.clone(),
988 body: body.to_owned(),
989 path: path.map(str::to_owned),
990 line,
991 verdict: a.verdict,
992 created_at: rfc3339(now),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts993 edited_at: None,
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar994 agent: None,
995 acting_for: None,
996 advisory: false,
Inbox: the events service tells people what needs them as events arrive997 };
998 self.db
999 .batch(vec![
1000 self.db
1001 .prepare(
1002 "INSERT INTO comments
1003 (id, repo_id, number, author_id, author_name, body, path, line,
1004 verdict, created_at)
1005 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1006 )
1007 .bind(&[
1008 comment.id.as_str().into(),
1009 repo.id.as_str().into(),
1010 a.number.into(),
1011 a.actor.id.as_str().into(),
1012 a.actor.username.as_str().into(),
1013 body.into(),
1014 optional(&comment.path),
1015 optional_number(line),
1016 a.verdict
1017 .map_or(JsValue::NULL, |verdict| verdict.as_str().into()),
1018 comment.created_at.as_str().into(),
1019 ])?,
1020 self.db
1021 .prepare(format!(
1022 "UPDATE {table} SET updated_at = ? WHERE repo_id = ? AND number = ?"
1023 ))
1024 .bind(&[
1025 comment.created_at.as_str().into(),
1026 repo.id.as_str().into(),
1027 a.number.into(),
1028 ])?,
1029 ])
1030 .await?;
1031 self.publish(
1032 "comment.created",
1033 &repo.id,
1034 &a.actor,
1035 CommentCreated {
1036 comment_id: comment.id.clone(),
1037 repo_id: repo.id.clone(),
1038 number: a.number,
1039 pull_id,
1040 verdict: a.verdict,
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar1041 ..CommentCreated::default()
Inbox: the events service tells people what needs them as events arrive1042 },
1043 )
1044 .await?;
Merge branch 'worktree-agent-ab8033ab973f8d99a'1045 // An approval is a decision: a person's is approved.
1046 if table == "pulls" && a.verdict == Some(Verdict::Approve) {
1047 let said = if body.is_empty() { String::new() } else { format!(": {}", body.chars().take(300).collect::<String>()) };
1048 self.keep_decision_quietly(
1049 &repo.id,
1050 decisions::NewDecision {
1051 issue: pull_issue,
1052 pull: Some(a.number),
1053 session_id: None,
1054 actor: Some(&a.actor),
1055 kind: g1t_contracts::decisions::DecisionKind::Approval,
1056 confidence: decisions::approval_confidence(&a.actor),
1057 summary: format!("{} approved #{}{said}", a.actor.username, a.number),
1058 evidence: serde_json::json!({ "comment": comment.id }),
1059 },
1060 )
1061 .await;
1062 }
Inbox: the events service tells people what needs them as events arrive1063 Ok(Outcome::Ok(comment))
1064 }
1065
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1066 /// A comment in the repository, with the repository and the pull
1067 /// request it is on (if it is on one), when `actor` may edit it, or
1068 /// with `deleting`, delete it (`may_change_comment`).
1069 async fn changeable_comment(
1070 &self,
1071 a: &CommentActionArgs,
1072 deleting: bool,
1073 ) -> Result<Outcome<(Repo, CommentRow, Option<String>)>> {
1074 let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?);
1075 check!(writable(&repo));
1076 let Some(row) = self
1077 .db
1078 .prepare("SELECT * FROM comments WHERE id = ? AND repo_id = ?")
1079 .bind(&[a.comment_id.trim().into(), repo.id.as_str().into()])?
1080 .first::<CommentRow>(None)
1081 .await?
1082 else {
1083 return Ok(Outcome::fail(FailureCode::NotFound, "Comment not found."));
1084 };
1085 let maintains = access::can(Some(&a.actor), &repo, Capability::ManageSettings);
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar1086 // What an agent wrote as itself is answered for by whoever it acted for.
1087 let author_id = row.answerable_id().to_owned();
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1088 if let Err(refusal) = may_change_comment(
1089 row.kind,
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar1090 row.has_verdict(),
1091 &author_id,
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1092 &a.actor.id,
1093 maintains,
1094 deleting,
1095 ) {
1096 // Someone who may change it otherwise was refused for what it is.
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar1097 let code = if author_id == a.actor.id || maintains { FailureCode::Conflict } else { FailureCode::Forbidden };
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1098 return Ok(Outcome::fail(code, refusal));
1099 }
1100 let pull_id = self.pull(&repo.id, row.number).await?.map(|pull| pull.id);
1101 Ok(Outcome::Ok((repo, row, pull_id)))
1102 }
1103
1104 /// Changes the text of a comment: its author's to do, or a
1105 /// maintainer's. Publishes `comment.edited` with what it said before.
1106 async fn edit_comment(&self, a: CommentActionArgs) -> Result<Outcome<Comment>> {
1107 let body = a.body.trim().to_owned();
1108 if body.chars().count() > MAX_ENTRY_CHARS {
1109 return Ok(Outcome::fail(FailureCode::Invalid, "That comment is too long."));
1110 }
1111 let (repo, row, pull_id) = check!(self.changeable_comment(&a, false).await?);
1112 // An approval speaks for itself; anything else has to say something.
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar1113 if body.is_empty() && row.verdict != Some(Verdict::Approve) && row.agent_verdict.as_deref() != Some("approve") {
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1114 return Ok(Outcome::fail(FailureCode::Invalid, "A comment cannot be empty."));
1115 }
1116 let number = row.number;
1117 let mut comment = Comment::from(row);
1118 if comment.body == body {
1119 return Ok(Outcome::Ok(comment));
1120 }
1121 let now = rfc3339(now_ms());
1122 self.db
1123 .prepare("UPDATE comments SET body = ?, edited_at = ? WHERE id = ?")
1124 .bind(&[body.as_str().into(), now.as_str().into(), comment.id.as_str().into()])?
1125 .run()
1126 .await?;
1127 let before = std::mem::replace(&mut comment.body, body);
1128 comment.edited_at = Some(now);
1129 self.publish(
1130 "comment.edited",
1131 &repo.id,
1132 &a.actor,
1133 CommentEdited {
1134 comment_id: comment.id.clone(),
1135 repo_id: repo.id.clone(),
1136 number,
1137 pull_id,
1138 changes: CommentChanges { body: ChangedFrom { from: before } },
1139 },
1140 )
1141 .await?;
1142 Ok(Outcome::Ok(comment))
1143 }
1144
1145 /// Deletes a comment: its author's to do, or a maintainer's. A review
1146 /// that gave a verdict stays. Publishes `comment.deleted` with the
1147 /// comment as it was.
1148 async fn delete_comment(&self, a: CommentActionArgs) -> Result<Outcome<bool>> {
1149 let (repo, row, pull_id) = check!(self.changeable_comment(&a, true).await?);
1150 self.db
1151 .prepare("DELETE FROM comments WHERE id = ?")
1152 .bind(&[row.id.as_str().into()])?
1153 .run()
1154 .await?;
1155 let number = row.number;
1156 let comment = Comment::from(row);
1157 self.publish(
1158 "comment.deleted",
1159 &repo.id,
1160 &a.actor,
1161 CommentDeleted {
1162 comment_id: comment.id.clone(),
1163 repo_id: repo.id.clone(),
1164 number,
1165 pull_id,
1166 comment: DeletedComment {
1167 id: comment.id,
1168 body: comment.body,
1169 author: (&comment.author).into(),
1170 created_at: comment.created_at,
1171 path: comment.path,
1172 line: comment.line,
1173 },
1174 },
1175 )
1176 .await?;
1177 Ok(Outcome::Ok(true))
1178 }
1179
Inbox: the events service tells people what needs them as events arrive1180 // --- Pull requests -----------------------------------------------------
1181
1182 async fn open_pull(&self, a: OpenPullArgs) -> Result<Outcome<Pull>> {
1183 if !a.actor.verified {
1184 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
1185 }
1186 let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?);
1187 check!(writable(&repo));
1188 let issue = match a.issue {
1189 Some(number) => match self.issue(&repo.id, number).await? {
1190 Some(issue) if issue.state == State::Open => Some(issue),
1191 Some(_) => {
1192 return Ok(Outcome::fail(
1193 FailureCode::Conflict,
1194 "This issue is closed.",
1195 ));
1196 }
1197 None => return Ok(no_issue()),
1198 },
1199 None => None,
1200 };
1201 // A pull request for an issue takes the issue's title unless given one.
1202 let title = match (a.title.trim(), &issue) {
1203 ("", Some(issue)) => issue.title.clone(),
1204 (title, _) => match valid_title(title) {
1205 Ok(title) => title.to_owned(),
1206 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
1207 },
1208 };
Pull requests: unnamed, a pull request is its author's, not an agent's1209 // Unnamed, the change is its author's, unless an agent opened it.
Inbox: the events service tells people what needs them as events arrive1210 let agent = match a.agent.trim() {
Pull requests: unnamed, a pull request is its author's, not an agent's1211 "" if g1t_contracts::rules::is_agent(&a.actor) => "agent",
1212 "" => a.actor.username.as_str(),
Inbox: the events service tells people what needs them as events arrive1213 agent => agent,
1214 };
1215 let runtime = match a.runtime {
1216 Runtime::Hosted => "hosted",
1217 Runtime::External => "external",
1218 };
1219
1220 let now = now_ms();
1221 let id = new_id("pr", now);
1222 let branch = a
1223 .branch
1224 .as_deref()
1225 .map(str::trim)
1226 .filter(|branch| !branch.is_empty());
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1227 // The branch it merges into: the default branch unless another is
1228 // asked for, which has to exist.
1229 let base = a.base.as_deref().and_then(|base| stored_base(base, &repo));
1230 if let Some(base) = &base {
1231 if branch == Some(base.as_str()) {
1232 return Ok(Outcome::fail(
1233 FailureCode::Invalid,
1234 format!("A pull request cannot merge {base} into itself. Choose another base."),
1235 ));
1236 }
1237 let exists: Option<String> = g1t_kit::call(
1238 &self.repos,
1239 "head",
1240 &HeadArgs { repo_id: repo.id.clone(), branch: base.clone() },
1241 )
1242 .await?;
1243 if exists.is_none() {
1244 return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no branch named {base} to merge into.")));
1245 }
1246 }
1247 let base_name = base.clone().unwrap_or_else(|| repo.default_branch.clone());
Inbox: the events service tells people what needs them as events arrive1248 // The change is on a branch already pushed to the repository, or
1249 // will be made in a fork created for this pull request.
1250 let (fork, head) = match branch {
1251 Some(branch) => {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1252 if branch == base_name {
Inbox: the events service tells people what needs them as events arrive1253 return Ok(Outcome::fail(
1254 FailureCode::Invalid,
1255 format!("Choose a branch other than {branch}."),
1256 ));
1257 }
1258 let head: Option<String> = g1t_kit::call(
1259 &self.repos,
1260 "head",
1261 &HeadArgs {
1262 repo_id: repo.id.clone(),
1263 branch: branch.to_owned(),
1264 },
1265 )
1266 .await?;
1267 let Some(head) = head else {
1268 return Ok(Outcome::fail(
1269 FailureCode::NotFound,
1270 format!("There is no branch named {branch}. Push it first."),
1271 ));
1272 };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1273 // One open pull request for each branch and base.
Inbox: the events service tells people what needs them as events arrive1274 let existing = self
1275 .db
1276 .prepare(
1277 "SELECT number AS n FROM pulls
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1278 WHERE repo_id = ? AND source_branch = ? AND status IN ('draft', 'open')
1279 AND base_branch IS ?",
Inbox: the events service tells people what needs them as events arrive1280 )
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1281 .bind(&[repo.id.as_str().into(), branch.into(), optional(&base)])?
Inbox: the events service tells people what needs them as events arrive1282 .first::<NumberRow>(None)
1283 .await?;
1284 if let Some(existing) = existing {
1285 return Ok(Outcome::fail(
1286 FailureCode::Conflict,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1287 format!("Pull request #{} is already open from {branch} into {base_name}.", existing.n),
Inbox: the events service tells people what needs them as events arrive1288 ));
1289 }
1290 (None, Some(head))
1291 }
1292 None => {
1293 let fork: Outcome<Repo> = g1t_kit::call(
1294 &self.repos,
1295 "fork_for_pull",
1296 &ForkArgs {
1297 source_id: repo.id.clone(),
1298 pull_id: id.clone(),
1299 actor: a.actor.clone(),
1300 },
1301 )
1302 .await?;
1303 (Some(check!(fork)), None)
1304 }
1305 };
1306 // A branch already holds the work, so its pull request is ready for
1307 // review from the start; one with a fork starts as a draft.
Open a pull request as a draft: it can't merge, and agents' review routines wait, until it is marked ready1308 // Asked for as a draft, it waits until it is marked ready.
1309 let status = if branch.is_some() && !a.draft { "open" } else { "draft" };
Inbox: the events service tells people what needs them as events arrive1310 let body = Some(a.body.trim().to_owned()).filter(|body| !body.is_empty());
1311
1312 let number = self.next_number(&repo.id).await?;
1313 let timestamp = rfc3339(now);
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.1314 let (author, requested_by) = authorship(&a.actor, false);
Inbox: the events service tells people what needs them as events arrive1315 self.db
1316 .prepare(
1317 "INSERT INTO pulls
1318 (id, repo_id, number, issue_id, issue_number, title, body, agent, runtime,
1319 status, fork_repo_id, fork_namespace, fork_name, source_branch, head_commit,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1320 author_id, author_name, requested_by_id, requested_by_name, created_at, updated_at,
1321 base_branch)
1322 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
Inbox: the events service tells people what needs them as events arrive1323 )
1324 .bind(&[
1325 id.as_str().into(),
1326 repo.id.as_str().into(),
1327 number.into(),
1328 optional(&issue.as_ref().map(|issue| issue.id.clone())),
1329 optional_number(issue.as_ref().map(|issue| issue.number)),
1330 title.into(),
1331 optional(&body),
1332 agent.into(),
1333 runtime.into(),
1334 status.into(),
1335 optional(&fork.as_ref().map(|fork| fork.id.clone())),
1336 optional(&fork.as_ref().map(|fork| fork.namespace.clone())),
1337 optional(&fork.as_ref().map(|fork| fork.name.clone())),
1338 optional(&branch.map(str::to_owned)),
1339 optional(&head),
1340 author.id.as_str().into(),
1341 author.username.as_str().into(),
1342 optional(&requested_by.as_ref().map(|user| user.id.clone())),
1343 optional(&requested_by.as_ref().map(|user| user.username.clone())),
1344 timestamp.as_str().into(),
1345 timestamp.as_str().into(),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1346 optional(&base),
Inbox: the events service tells people what needs them as events arrive1347 ])?
1348 .run()
1349 .await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1350 let Some(mut pull) = self.pull(&repo.id, number).await? else {
Inbox: the events service tells people what needs them as events arrive1351 return Ok(no_pull());
1352 };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1353 fill_base(&mut pull, &repo);
Inbox: the events service tells people what needs them as events arrive1354 if let Some(issue) = pull.issue {
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.1355 let text = format!("opened #{} for this", pull.number);
Inbox: the events service tells people what needs them as events arrive1356 self.note(&repo.id, issue, (&a.actor.id, &a.actor.username), &text)
1357 .await?;
1358 }
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1359 // Its head and merge refs in the repository, before its events go out.
1360 self.keep_pull_refs(&pull).await;
Inbox: the events service tells people what needs them as events arrive1361 self.publish(
1362 "pull.opened",
1363 &repo.id,
1364 &a.actor,
1365 PullEvent {
1366 agent: Some(pull.agent.clone()),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1367 base: pull.base.clone(),
Inbox: the events service tells people what needs them as events arrive1368 ..Self::pull_event(&pull)
1369 },
1370 )
1371 .await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1372 // Its code owners asked to review (codeowners.rs).
1373 self.refresh_code_owners(&pull).await;
1374 let pull = self.pull(&repo.id, number).await?.unwrap_or(pull);
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'1375 // A branch pushed before may overlap others from the start.
1376 self.announce_overlaps(&pull, Some(a.actor.id.clone())).await;
Inbox: the events service tells people what needs them as events arrive1377 Ok(Outcome::Ok(pull))
1378 }
1379
1380 async fn list_pulls(&self, a: ListPullsArgs) -> Result<Outcome<Vec<Pull>>> {
1381 let filter = match a.state {
1382 Some(State::Open) => "AND status IN ('draft', 'open')",
1383 Some(State::Closed) => "AND status IN ('merged', 'closed')",
1384 None => "",
1385 };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1386 let label = a
1387 .label
1388 .map(|label| label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase())
1389 .filter(|label| !label.is_empty());
1390 let milestone = a.milestone;
1391 let read = |repo_id: String| {
1392 let label = label.clone();
1393 async move {
1394 let query = self
1395 .db
1396 .prepare(format!(
1397 "SELECT {PULL_COLUMNS} FROM pulls WHERE repo_id = ? {filter}
1398 AND (? IS NULL OR EXISTS
1399 (SELECT 1 FROM json_each(pulls.labels) WHERE json_each.value = ?))
1400 AND (? IS NULL OR milestone = ?)
1401 ORDER BY number DESC LIMIT ?"
1402 ))
1403 .bind(&[
1404 repo_id.into(),
1405 optional(&label),
1406 optional(&label),
1407 optional_number(milestone),
1408 optional_number(milestone),
1409 LIST_PAGE.into(),
1410 ])?;
1411 self.timing.db(1, query.all()).await?.results::<PullRow>()
1412 }
Inbox: the events service tells people what needs them as events arrive1413 };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1414 let (repo, rows) = check!(self.repo_then(&a.repo, &a.viewer, read).await?);
1415 let base = a.base.map(|base| base.trim().to_owned()).filter(|base| !base.is_empty());
1416 Ok(Outcome::Ok(
1417 rows.into_iter()
1418 .map(|row| {
1419 let mut pull = Pull::from(row);
1420 fill_base(&mut pull, &repo);
1421 pull
1422 })
1423 .filter(|pull| base.as_deref().is_none_or(|base| pull.base.as_deref() == Some(base)))
1424 .collect(),
1425 ))
Inbox: the events service tells people what needs them as events arrive1426 }
1427
1428 /// `pulls_for_repos`: what `list_pulls` gives, open and closed, for many
1429 /// repositories at once: one access check with repos for all of them
1430 /// and one query, instead of two of each per repository.
1431 async fn pulls_for_repos(&self, a: PullsForReposArgs) -> Result<Vec<RepoPulls>> {
1432 let ids: Vec<String> = a.repo_ids.into_iter().take(MAX_PULLS_FOR_REPOS).collect();
1433 if ids.is_empty() {
1434 return Ok(Vec::new());
1435 }
1436 let limit = a.limit.clamp(1, LIST_PAGE);
1437 // The rows are read beside the access check, for every id asked
1438 // about; those of repositories the viewer cannot read are dropped.
1439 let asked = serde_json::to_string(&ids)?;
1440 let check = ReadableArgs { ids, viewer: a.viewer };
1441 let readable = self.timing.rpc(g1t_kit::call::<_, Vec<Repo>>(&self.repos, "readable", &check));
1442 let (readable, rows) = try_join(readable, self.timing.db(1, self.newest_pulls(asked, limit))).await?;
1443 if readable.is_empty() {
1444 return Ok(Vec::new());
1445 }
1446 let mut answer: Vec<RepoPulls> = readable
1447 .iter()
1448 .map(|repo| RepoPulls { repo_id: repo.id.clone(), open: Vec::new(), closed: Vec::new() })
1449 .collect();
1450 for pull in rows.into_iter().map(Pull::from) {
1451 let Some(entry) = answer.iter_mut().find(|entry| entry.repo_id == pull.repo_id) else {
1452 continue;
1453 };
1454 match pull.status {
1455 PullStatus::Draft | PullStatus::Open => entry.open.push(pull),
1456 PullStatus::Merged | PullStatus::Closed => entry.closed.push(pull),
1457 }
1458 }
1459 for entry in &mut answer {
1460 entry.open.sort_by_key(|pull| std::cmp::Reverse(pull.number));
1461 entry.closed.sort_by_key(|pull| std::cmp::Reverse(pull.number));
1462 }
1463 Ok(answer)
1464 }
1465
1466 /// The newest `limit` of each repository's open (draft or open) and
1467 /// closed (merged or closed) pull requests, for the ids in `ids` (JSON).
1468 async fn newest_pulls(&self, ids: String, limit: u32) -> Result<Vec<PullRow>> {
1469 self.db
1470 .prepare(format!(
1471 "SELECT * FROM (
1472 SELECT {PULL_COLUMNS}, ROW_NUMBER() OVER (
1473 PARTITION BY pulls.repo_id, pulls.status IN ('draft', 'open') ORDER BY pulls.number DESC
1474 ) AS place
1475 FROM pulls WHERE pulls.repo_id IN (SELECT value FROM json_each(?1))
1476 ) WHERE place <= ?2"
1477 ))
1478 .bind(&[ids.into(), limit.into()])?
1479 .all()
1480 .await?
1481 .results::<PullRow>()
1482 }
1483
1484 async fn get_pull(&self, a: ViewArgs) -> Result<Outcome<PullDetail>> {
1485 let number = a.number;
1486 // Every row the page and the lifecycle read, in one batch started
1487 // beside the access check; the helpers below read from it.
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1488 let namespace = a.repo.namespace.clone();
1489 let read = |repo_id: String| self.prefetch_pull(repo_id, namespace.clone(), number);
Inbox: the events service tells people what needs them as events arrive1490 let Outcome::Ok((repo, Some(found))) = self.repo_then(&a.repo, &a.viewer, read).await? else {
1491 return Ok(no_pull());
1492 };
1493 let Some(row) = found.first::<PullRow>(prefetch::Slot::Pull)? else {
1494 return Ok(no_pull());
1495 };
1496 let stored = found.first::<StoredBehind>(prefetch::Slot::Pull)?;
1497 let issue = found.first::<IssueRow>(prefetch::Slot::Issue)?.map(Issue::from);
1498 let comments: Vec<Comment> =
1499 found.rows::<CommentRow>(prefetch::Slot::Comments)?.into_iter().map(Comment::from).collect();
1500 self.keep_prefetched(Some(found));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1501 let default_branch = repo.default_branch.clone();
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1502 let detail = self.pull_detail(repo, Pull::from(row), issue, comments, stored, &a.viewer).await;
Inbox: the events service tells people what needs them as events arrive1503 self.keep_prefetched(None);
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1504 // The branch it merges into, named, for whoever reads it.
1505 Ok(match detail? {
1506 Outcome::Ok(mut detail) => {
1507 if detail.pull.base.as_deref().is_none_or(str::is_empty) {
1508 detail.pull.base = Some(default_branch);
1509 }
1510 Outcome::Ok(detail)
1511 }
1512 failed => failed,
1513 })
Inbox: the events service tells people what needs them as events arrive1514 }
1515
1516 async fn pull_detail(
1517 &self,
1518 repo: Repo,
1519 mut pull: Pull,
1520 issue: Option<Issue>,
1521 comments: Vec<Comment>,
1522 stored: Option<StoredBehind>,
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1523 viewer: &Viewer,
Inbox: the events service tells people what needs them as events arrive1524 ) -> Result<Outcome<PullDetail>> {
1525 // Whether it is behind, as worked out with its mergeability on the
1526 // last push to either side (mergeability.rs), when that was for
1527 // its head as it is now; otherwise asked of the repos service.
1528 let known_behind = stored.and_then(|stored| stored.for_head(pull.head_commit.as_deref()));
1529 // Worked out on each push; this covers a pull request from before
1530 // that was recorded.
1531 if pull.files.is_empty() && pull.head_commit.is_some() {
1532 pull.files = self.refresh_files(&pull).await?;
1533 }
1534 // Everything else at once: none of it depends on the rest, and each
1535 // is a round trip of its own.
1536 let standing = async {
1537 // Mergeability first: where g1t sees a pull request through, a
1538 // conflict decides its next step.
1539 let behind = async {
1540 match known_behind {
1541 Some(behind) => Ok(behind),
1542 None => {
1543 let behind = self.is_behind(&repo.id, &pull).await?;
1544 // Kept for the next view when the mergeability on
1545 // record is for this head: a pull request from
1546 // before `behind` was kept asks once.
1547 if let Some(head) = pull.head_commit.as_deref()
1548 && pull.status.is_active()
1549 {
1550 self.db
1551 .prepare(
1552 "UPDATE pulls SET behind = ?1
1553 WHERE id = ?2 AND behind IS NULL AND mergeable_key LIKE ?3 || '..%'",
1554 )
1555 .bind(&[u32::from(behind).into(), pull.id.as_str().into(), head.into()])?
1556 .run()
1557 .await?;
1558 }
1559 Ok(behind)
1560 }
1561 }
1562 };
1563 let (merge, behind) = try_join(self.mergeability(&pull), behind).await?;
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.1564 let lifecycle = self.assess(&pull, &issue, behind).await?.map(|(lifecycle, _)| lifecycle);
1565 Ok::<_, worker::Error>((behind, lifecycle, merge))
Inbox: the events service tells people what needs them as events arrive1566 };
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.1567 let (((behind, lifecycle, (mergeable, conflicts)), (landing, stalled), comments), (checks, overlaps)) =
Inbox: the events service tells people what needs them as events arrive1568 try_join(
1569 try_join3(standing, self.landing_state(&pull.id), async { Ok(comments) }),
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.1570 try_join(self.latest_checks(&pull.id), self.overlaps(&pull)),
Inbox: the events service tells people what needs them as events arrive1571 )
1572 .await?;
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1573 // The rules of the branch it merges into, as they stack, and which
1574 // of them it does not meet yet, for whoever is looking.
1575 let (statuses, settings, gate) = try_join3(
1576 self.statuses(&repo.id, pull.head_commit.as_deref()),
1577 self.settings_on(&repo, &pull),
1578 async {
1579 if pull.status.is_active() {
1580 self.merge_gate(&repo, &pull, viewer.as_ref(), false, true).await.map(Some)
1581 } else {
1582 Ok(None)
1583 }
1584 },
1585 )
1586 .await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1587 let code_owners = self.pull_code_owners(&pull, &comments, &settings).await?;
A required check named for a workflow job, as GitHub's branch rules name them, is met by that job. Each g1t Actions job on a pull request's head counts as a check of its name (`test`, `build (ubuntu-latest, 22)`, or `deploy / build` for a called workflow's job), from the latest attempt of its workflow: skipped passes, cancelled fails, and a job still going holds the merge. So a ruleset or required check copied from GitHub needs no renaming, and one that names the workflow, such as `CI`, is met by the whole run as before. This holds in the merge box, for the merge queue and for agents' merges; jobs count only toward required checks, so a `continue-on-error` job that fails doesn't send an agent back. The pull requests guide says so.1588 let required = if settings.required_checks.is_empty() {
1589 Vec::new()
1590 } else {
1591 let checked = self.with_job_checks(&repo.id, pull.head_commit.as_deref(), statuses.clone()).await?;
1592 required_checks(&settings.required_checks, &checked)
1593 };
Inbox: the events service tells people what needs them as events arrive1594 Ok(Outcome::Ok(PullDetail {
A required check named for a workflow job, as GitHub's branch rules name them, is met by that job. Each g1t Actions job on a pull request's head counts as a check of its name (`test`, `build (ubuntu-latest, 22)`, or `deploy / build` for a called workflow's job), from the latest attempt of its workflow: skipped passes, cancelled fails, and a job still going holds the merge. So a ruleset or required check copied from GitHub needs no renaming, and one that names the workflow, such as `CI`, is met by the whole run as before. This holds in the merge box, for the merge queue and for agents' merges; jobs count only toward required checks, so a `continue-on-error` job that fails doesn't send an agent back. The pull requests guide says so.1595 required_checks: required,
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1596 rules: gate.map(|gate| rulesets::merge_rules(&gate.judged, &gate.requirements, pull.base_branch(&repo.default_branch) == repo.default_branch)),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1597 code_owners,
Inbox: the events service tells people what needs them as events arrive1598 comments,
1599 checks,
1600 overlaps,
1601 behind,
1602 lifecycle,
1603 landing,
1604 stalled,
1605 statuses,
1606 mergeable,
1607 conflicts,
1608 earlier_checks: self.earlier_checks(&pull.id).await?,
1609 issue,
1610 pull,
1611 }))
1612 }
1613
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1614 /// The pull request, whatever its status, if its repository is not
1615 /// archived and `actor` opened it or may triage its pull requests.
1616 async fn managed_pull(
Inbox: the events service tells people what needs them as events arrive1617 &self,
1618 actor: &User,
1619 path: &RepoPath,
1620 number: u32,
1621 ) -> Result<Outcome<Pull>> {
1622 let (repo, pull) = check!(self.pull_at(path, number, &Some(actor.clone())).await?);
1623 check!(writable(&repo));
1624 if !pull.is_owned_by(&actor.id) {
1625 check!(allowed(Some(actor), &repo, Capability::Triage));
1626 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1627 Ok(Outcome::Ok(pull))
1628 }
1629
1630 /// The pull request, if it is still active and `actor` opened it or
1631 /// may triage the repository's pull requests.
1632 async fn manageable_pull(
1633 &self,
1634 actor: &User,
1635 path: &RepoPath,
1636 number: u32,
1637 ) -> Result<Outcome<Pull>> {
1638 let pull = check!(self.managed_pull(actor, path, number).await?);
Inbox: the events service tells people what needs them as events arrive1639 if !pull.status.is_active() {
1640 return Ok(Outcome::fail(
1641 FailureCode::Conflict,
1642 format!("This pull request is already {}.", pull.status.as_str()),
1643 ));
1644 }
1645 Ok(Outcome::Ok(pull))
1646 }
1647
1648 /// Brings a pull request up to date with the default branch without a
1649 /// sandbox, where the repos service can do that safely. Whoever could
1650 /// have pushed the merge themselves may ask: whoever opened it (or asked
1651 /// g1t for it), for a fork; anyone who may push, for a branch of the
1652 /// repository. When it needs a
1653 /// real merge, says so, naming the conflicting files if a probe found
1654 /// them, and pushes nothing.
1655 async fn catch_up_pull(&self, a: PullActionArgs) -> Result<Outcome<PullBranchUpdate>> {
1656 let (repo, pull) = check!(self.pull_at(&a.repo, a.number, &Some(a.actor.clone())).await?);
1657 check!(writable(&repo));
1658 if !pull.status.is_active() {
1659 return Ok(Outcome::fail(
1660 FailureCode::Conflict,
1661 format!("This pull request is already {}.", pull.status.as_str()),
1662 ));
1663 }
1664 if pull.fork_repo_id.is_some() {
1665 if !pull.is_owned_by(&a.actor.id) {
1666 return Ok(Outcome::fail(
1667 FailureCode::Forbidden,
1668 "Only whoever opened this pull request, or asked g1t for it, can update it.",
1669 ));
1670 }
1671 } else {
1672 check!(allowed(Some(&a.actor), &repo, Capability::Push));
1673 }
1674 let updated: Outcome<PullBranchUpdate> = g1t_kit::call(
1675 &self.repos,
1676 "update_pull_branch",
1677 &UpdatePullBranchArgs {
1678 source_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.id.clone()),
1679 branch: pull.branch.clone(),
1680 number: pull.number,
1681 actor: a.actor,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1682 target_branch: pull.base.clone(),
Inbox: the events service tells people what needs them as events arrive1683 },
1684 )
1685 .await?;
1686 // A probe that found conflicts says more than "both changed it".
1687 if let Outcome::Ok(PullBranchUpdate::NeedsAgent { .. }) = &updated
1688 && let Some(files) = self.conflicting_files(&pull).await?
1689 && !files.is_empty()
1690 {
1691 return Ok(Outcome::Ok(PullBranchUpdate::NeedsAgent {
1692 reason: NeedsAgentReason::Conflicting,
1693 detail: "Merging it conflicts.".to_owned(),
1694 paths: files,
1695 }));
1696 }
1697 Ok(updated)
1698 }
1699
1700 async fn update_pull(&self, a: UpdatePullArgs) -> Result<Outcome<Pull>> {
1701 let pull = check!(self.manageable_pull(&a.actor, &a.repo, a.number).await?);
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1702 // The branch it merges into, its milestone and its labels first:
1703 // each can be refused, and then nothing else changes.
1704 if a.base.is_some() || a.milestone.is_some() || a.labels.is_some() {
1705 let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?);
1706 if let Some(base) = &a.base {
1707 check!(self.change_base(&a.actor, &repo, &pull, base).await?);
1708 }
1709 if let Some(number) = a.milestone {
1710 check!(self.set_milestone(&a.actor, &repo, &labels::Item::Pull(pull.clone()), number).await?);
1711 }
1712 if let Some(labels) = &a.labels {
1713 check!(self.relabel(&a.actor, &repo, &labels::Item::Pull(pull.clone()), labels).await?);
1714 }
1715 }
Inbox: the events service tells people what needs them as events arrive1716 let assignees = match a.assignees {
1717 Some(names) => Some(check!(self.valid_assignees(names).await?)),
1718 None => None,
1719 };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1720 // Teams, named `workspace/team`, apart from the people.
1721 let (team_names, a_reviewers) = match a.reviewers {
1722 Some(names) => {
1723 let (teams, people): (Vec<String>, Vec<String>) =
1724 names.into_iter().partition(|name| team_reviews::team_name(name).is_some());
1725 (Some(teams), Some(people))
1726 }
1727 None => (None, None),
1728 };
1729 let teams = match team_names {
1730 Some(names) => Some(check!(self.valid_team_reviewers(&a.actor, &a.repo, &pull, names).await?)),
1731 None => None,
1732 };
1733 let reviewers = match a_reviewers {
Inbox: the events service tells people what needs them as events arrive1734 Some(names) => {
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.1735 let mut reviewers = check!(self.valid_assignees(names).await?);
Inbox: the events service tells people what needs them as events arrive1736 // Nobody is asked to review their own, nor what they had g1t make.
1737 reviewers.retain(|name| *name != pull.owner().username);
1738 Some(reviewers)
1739 }
1740 None => None,
1741 };
1742 self.db
1743 .prepare(
1744 "UPDATE pulls
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1745 SET assignees = COALESCE(?, assignees), updated_at = ?
Inbox: the events service tells people what needs them as events arrive1746 WHERE id = ?",
1747 )
1748 .bind(&[
1749 optional(&assignees.as_ref().map(serde_json::to_string).transpose()?),
1750 rfc3339(now_ms()).into(),
1751 pull.id.as_str().into(),
1752 ])?
1753 .run()
1754 .await?;
1755 if let Some(assignees) = &assignees {
1756 self.note_changes(
1757 &pull.repo_id,
1758 pull.number,
1759 &a.actor,
1760 &pull.assignees,
1761 assignees,
1762 ("assigned", "unassigned"),
1763 )
1764 .await?;
1765 }
Events: review requests, assignments, stops and deployments are published1766 // Who was newly assigned or asked to review, and whose request was
1767 // withdrawn: the inbox tells them, and webhooks say so.
1768 let newly = |after: &[String], before: &[String]| -> Vec<String> {
1769 after.iter().filter(|name| !before.contains(name)).cloned().collect()
1770 };
1771 if let Some(assignees) = &assignees {
1772 let added = newly(assignees, &pull.assignees);
1773 if !added.is_empty() {
1774 self.publish(
1775 "pull.assigned",
1776 &pull.repo_id,
1777 &a.actor,
1778 PullEvent {
1779 assignees: Some(assignees.clone()),
1780 added: Some(added),
1781 ..Self::pull_event(&pull)
1782 },
1783 )
1784 .await?;
1785 }
1786 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1787 if reviewers.is_some() || teams.is_some() {
1788 let people = reviewers.unwrap_or_else(|| pull.reviewers.clone());
1789 let teams = teams.unwrap_or_else(|| pull.team_reviewers.clone());
1790 self.set_reviewers(&pull, people, teams, Some(&a.actor), false).await?;
Events: review requests, assignments, stops and deployments are published1791 }
Inbox: the events service tells people what needs them as events arrive1792 Ok(match self.pull(&pull.repo_id, pull.number).await? {
1793 Some(pull) => Outcome::Ok(pull),
1794 None => no_pull(),
1795 })
1796 }
1797
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1798 /// Points an open pull request at another branch to merge into. Needs
1799 /// the Write role. What it would merge, whether it is behind, its
1800 /// mergeability and its checks are all worked out against the new base.
1801 async fn change_base(&self, actor: &User, repo: &Repo, pull: &Pull, base: &str) -> Result<Outcome<()>> {
1802 check!(allowed(Some(actor), repo, Capability::Push));
1803 let base = base.trim();
1804 if base.is_empty() {
1805 return Ok(Outcome::fail(FailureCode::Invalid, "Name the branch it should merge into."));
1806 }
1807 let before = pull.base_branch(&repo.default_branch).to_owned();
1808 if base == before {
1809 return Ok(Outcome::Ok(()));
1810 }
1811 if pull.fork_repo_id.is_none() && pull.branch.as_deref() == Some(base) {
1812 return Ok(Outcome::fail(
1813 FailureCode::Invalid,
1814 format!("A pull request cannot merge {base} into itself. Choose another base."),
1815 ));
1816 }
1817 let exists: Option<String> = g1t_kit::call(
1818 &self.repos,
1819 "head",
1820 &HeadArgs { repo_id: repo.id.clone(), branch: base.to_owned() },
1821 )
1822 .await?;
1823 if exists.is_none() {
1824 return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no branch named {base} to merge into.")));
1825 }
1826 // In the merge queue it was headed for the default branch; it
1827 // leaves the queue for another base.
1828 let left_queue = self
1829 .leave(&pull.repo_id, pull, QueueState::Removed, Some("Its base branch changed."))
1830 .await?;
1831 let stored = stored_base(base, repo);
1832 self.db
1833 .prepare(
1834 "UPDATE pulls
1835 SET base_branch = ?, updated_at = ?, land_requested = NULL, land_requested_at = NULL, behind = NULL,
1836 mergeable = NULL, mergeable_key = NULL, conflicts = NULL
1837 WHERE id = ?",
1838 )
1839 .bind(&[optional(&stored), rfc3339(now_ms()).into(), pull.id.as_str().into()])?
1840 .run()
1841 .await?;
1842 self.note(
1843 &pull.repo_id,
1844 pull.number,
1845 (&actor.id, &actor.username),
1846 &format!("changed the base branch from `{before}` to `{base}`"),
1847 )
1848 .await?;
1849 self.publish(
1850 "pull.base_changed",
1851 &pull.repo_id,
1852 actor,
1853 PullEvent { base: Some(base.to_owned()), ..Self::pull_event(pull) },
1854 )
1855 .await?;
1856 if left_queue {
1857 self.publish_as(
1858 "queue.changed",
1859 &pull.repo_id,
1860 None,
1861 g1t_contracts::events::QueueChanged { repo_id: pull.repo_id.clone() },
1862 )
1863 .await?;
1864 }
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1865 // Its merge ref, and whether it merges cleanly, against the new base.
1866 if let Some(moved) = self.pull_by_id(&pull.id).await? {
1867 self.keep_pull_refs(&moved).await;
1868 if let Err(error) = self.assess_mergeability(&moved).await {
1869 worker::console_warn!("mergeability of {}: {error}", pull.id);
1870 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1871 }
1872 Ok(Outcome::Ok(()))
1873 }
1874
Inbox: the events service tells people what needs them as events arrive1875 /// Marks a draft ready for review, or updates the description of one
1876 /// that already is.
1877 async fn ready_pull(&self, a: PullActionArgs) -> Result<Outcome<Pull>> {
1878 let mut pull = check!(self.manageable_pull(&a.actor, &a.repo, a.number).await?);
1879 let summary = Some(a.summary.trim().to_owned()).filter(|summary| !summary.is_empty());
1880 let now = rfc3339(now_ms());
1881 self.db
1882 .prepare(
1883 "UPDATE pulls SET status = 'open', body = COALESCE(?, body), updated_at = ?
1884 WHERE id = ?",
1885 )
1886 .bind(&[
1887 optional(&summary),
1888 now.as_str().into(),
1889 pull.id.as_str().into(),
1890 ])?
1891 .run()
1892 .await?;
1893 if pull.status == PullStatus::Draft {
1894 // The head as it is now: the push that came just before may not
1895 // have reached `head_commit` yet, and workflows run on it.
1896 let commit = self.live_head(&pull).await?.or_else(|| pull.head_commit.clone());
1897 self.publish(
1898 "pull.ready",
1899 &pull.repo_id,
1900 &a.actor,
1901 PullEvent {
1902 commit,
1903 ..Self::pull_event(&pull)
1904 },
1905 )
1906 .await?;
1907 }
1908 if pull.status == PullStatus::Draft {
1909 self.note(
1910 &pull.repo_id,
1911 pull.number,
1912 (&a.actor.id, &a.actor.username),
1913 "marked this ready for review",
1914 )
1915 .await?;
1916 }
1917 pull.status = PullStatus::Open;
1918 pull.body = summary.or(pull.body);
1919 pull.updated_at = now;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1920 // A draft's code owners are asked once it is ready.
1921 self.refresh_code_owners(&pull).await;
1922 if let Some(fresh) = self.pull(&pull.repo_id, pull.number).await? {
1923 pull.reviewers = fresh.reviewers;
1924 pull.team_reviewers = fresh.team_reviewers;
1925 }
Inbox: the events service tells people what needs them as events arrive1926 Ok(Outcome::Ok(pull))
1927 }
1928
1929 async fn close_pull(&self, a: PullActionArgs) -> Result<Outcome<Pull>> {
1930 let mut pull = check!(self.manageable_pull(&a.actor, &a.repo, a.number).await?);
1931 let now = rfc3339(now_ms());
1932 self.db
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1933 // What it was closed as, so that reopening brings that back.
1934 .prepare("UPDATE pulls SET status = 'closed', closed_from = ?, updated_at = ? WHERE id = ?")
1935 .bind(&[pull.status.as_str().into(), now.as_str().into(), pull.id.as_str().into()])?
Inbox: the events service tells people what needs them as events arrive1936 .run()
1937 .await?;
1938 self.publish(
1939 "pull.closed",
1940 &pull.repo_id,
1941 &a.actor,
1942 Self::pull_event(&pull),
1943 )
1944 .await?;
1945 self.note(
1946 &pull.repo_id,
1947 pull.number,
1948 (&a.actor.id, &a.actor.username),
1949 "closed this",
1950 )
1951 .await?;
1952 // A closed pull request leaves the merge queue.
1953 if self
1954 .leave(&pull.repo_id, &pull, QueueState::Removed, Some("It was closed."))
1955 .await?
1956 {
1957 self.publish_as(
1958 "queue.changed",
1959 &pull.repo_id,
1960 None,
1961 g1t_contracts::events::QueueChanged {
1962 repo_id: pull.repo_id.clone(),
1963 },
1964 )
1965 .await?;
1966 }
1967 pull.status = PullStatus::Closed;
1968 pull.updated_at = now;
1969 Ok(Outcome::Ok(pull))
1970 }
1971
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1972 /// Opens a closed pull request again: as the draft it was, if it was
1973 /// closed as one, else ready for review. A merged one stays merged.
1974 async fn reopen_pull(&self, a: PullActionArgs) -> Result<Outcome<Pull>> {
1975 let mut pull = check!(self.managed_pull(&a.actor, &a.repo, a.number).await?);
1976 if let Some(refusal) = reopen_refusal(pull.status) {
1977 return Ok(Outcome::fail(FailureCode::Conflict, refusal));
1978 }
1979 // The head as it is now, which workflows run on. A branch of the
1980 // repository that was deleted since leaves nothing to reopen; a
1981 // fork removed after the close is made again (repos, `pull.reopened`).
1982 let head = self.live_head(&pull).await?;
1983 if head.is_none() && pull.fork_repo_id.is_none() {
1984 return Ok(Outcome::fail(
1985 FailureCode::Conflict,
1986 format!(
1987 "The branch {} no longer exists. Push it again to reopen this pull request.",
1988 pull.branch.as_deref().unwrap_or_default()
1989 ),
1990 ));
1991 }
1992 let closed_from: Option<ClosedFrom> = self
1993 .db
1994 .prepare("SELECT closed_from FROM pulls WHERE id = ?")
1995 .bind(&[pull.id.as_str().into()])?
1996 .first(None)
1997 .await?;
1998 let status = reopened_status(closed_from.and_then(|row| row.closed_from).as_deref());
1999 let now = rfc3339(now_ms());
2000 self.db
2001 .prepare("UPDATE pulls SET status = ?, closed_from = NULL, superseded_by = NULL, updated_at = ? WHERE id = ?")
2002 .bind(&[status.as_str().into(), now.as_str().into(), pull.id.as_str().into()])?
2003 .run()
2004 .await?;
2005 self.publish(
2006 "pull.reopened",
2007 &pull.repo_id,
2008 &a.actor,
2009 PullEvent {
2010 commit: head.or_else(|| pull.head_commit.clone()),
2011 ..Self::pull_event(&pull)
2012 },
2013 )
2014 .await?;
2015 self.note(
2016 &pull.repo_id,
2017 pull.number,
2018 (&a.actor.id, &a.actor.username),
2019 "reopened this",
2020 )
2021 .await?;
2022 pull.status = status;
2023 pull.superseded_by = None;
2024 pull.updated_at = now;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'2025 // Its refs in the repository, and whether it still merges cleanly,
2026 // now that it is open again.
2027 self.keep_pull_refs(&pull).await;
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts2028 if let Err(error) = self.assess_mergeability(&pull).await {
2029 worker::console_warn!("mergeability of {}: {error}", pull.id);
2030 }
2031 Ok(Outcome::Ok(pull))
2032 }
2033
2034 /// Turns a pull request that is ready for review back into a draft: it
2035 /// cannot be merged until it is marked ready again, and it leaves the
2036 /// merge queue and any merge that was waiting for it to catch up.
2037 async fn convert_pull_to_draft(&self, a: PullActionArgs) -> Result<Outcome<Pull>> {
2038 let mut pull = check!(self.managed_pull(&a.actor, &a.repo, a.number).await?);
2039 if let Some(refusal) = draft_refusal(pull.status) {
2040 return Ok(Outcome::fail(FailureCode::Conflict, refusal));
2041 }
2042 let now = rfc3339(now_ms());
2043 self.db
2044 .prepare(
2045 "UPDATE pulls SET status = 'draft', land_requested = NULL, land_requested_at = NULL, updated_at = ?
2046 WHERE id = ?",
2047 )
2048 .bind(&[now.as_str().into(), pull.id.as_str().into()])?
2049 .run()
2050 .await?;
2051 self.publish(
2052 "pull.converted_to_draft",
2053 &pull.repo_id,
2054 &a.actor,
2055 Self::pull_event(&pull),
2056 )
2057 .await?;
2058 self.note(
2059 &pull.repo_id,
2060 pull.number,
2061 (&a.actor.id, &a.actor.username),
2062 "marked this as a draft",
2063 )
2064 .await?;
2065 if self
2066 .leave(&pull.repo_id, &pull, QueueState::Removed, Some("It was marked as a draft."))
2067 .await?
2068 {
2069 self.publish_as(
2070 "queue.changed",
2071 &pull.repo_id,
2072 None,
2073 g1t_contracts::events::QueueChanged {
2074 repo_id: pull.repo_id.clone(),
2075 },
2076 )
2077 .await?;
2078 }
2079 pull.status = PullStatus::Draft;
2080 pull.updated_at = now;
2081 Ok(Outcome::Ok(pull))
2082 }
2083
Inbox: the events service tells people what needs them as events arrive2084 /// Lands the pull request on the repository's default branch. Unless
2085 /// told to keep it open, that resolves the issue it was for: the issue
2086 /// closes naming this pull request, and the others still in progress
2087 /// for it close as superseded.
2088 async fn merge_pull(&self, a: PullActionArgs) -> Result<Outcome<Pull>> {
2089 let viewer = Some(a.actor.clone());
2090 let (repo, pull) = check!(self.pull_at(&a.repo, a.number, &viewer).await?);
2091 check!(writable(&repo));
2092 match pull.status {
2093 PullStatus::Open => {}
2094 PullStatus::Draft => {
2095 return Ok(Outcome::fail(
2096 FailureCode::Conflict,
2097 "This pull request is still a draft. Mark it ready for review first.",
2098 ));
2099 }
2100 status => {
2101 return Ok(Outcome::fail(
2102 FailureCode::Conflict,
2103 format!("This pull request is already {}.", status.as_str()),
2104 ));
2105 }
2106 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2107 let base = pull.base_branch(&repo.default_branch).to_owned();
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2108 // The rules of the branch it merges into, as they stack: the one
2109 // gate for the merge button, the API, MCP, auto-merge and the queue,
2110 // for a person's pull request and an agent's alike. A bypass counts
2111 // when the merger asks for it, or for g1t when a ruleset lists it.
2112 let gate = self.merge_gate(&repo, &pull, Some(&a.actor), a.ignore_checks, true).await?;
2113 let bypassable = gate.bypassable();
2114 let gate = if a.bypass_rules || a.actor.is_system() { gate } else { gate.without_bypass() };
2115 let settings = rulesets::overlay(self.settings(&repo.id).await?, &gate.requirements, base == repo.default_branch);
2116 if pull.check_status == Some(CheckStatus::Failed) && !(a.ignore_checks && settings.allow_ignoring_checks) {
2117 return Ok(Outcome::fail(
2118 FailureCode::Conflict,
2119 "It failed in the merge queue; push a fix to try again.",
2120 ));
2121 }
2122 if let Some(refusal) = gate.refusal() {
2123 if access::can(Some(&a.actor), &repo, Capability::Merge) {
2124 self.record_merge_evaluations(&repo, &pull, &gate).await;
Inbox: the events service tells people what needs them as events arrive2125 }
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2126 let offer = if bypassable && !a.bypass_rules {
2127 " You may bypass these rules: merge again and ask to bypass them (bypass_rules)."
2128 } else {
2129 ""
2130 };
2131 return Ok(Outcome::fail(FailureCode::Conflict, format!("{refusal}{offer}")));
Inbox: the events service tells people what needs them as events arrive2132 }
2133 // Known ahead of time to conflict: neither a merge nor the queue
2134 // would get through, so say what has to be resolved now.
2135 if let Some(files) = self.conflicting_files(&pull).await? {
2136 let named = if files.is_empty() {
2137 String::new()
2138 } else {
2139 format!(" in {}", files.join(", "))
2140 };
2141 return Ok(Outcome::fail(
2142 FailureCode::Conflict,
2143 format!(
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.2144 "This branch has conflicts with {base}{named} that must be resolved first. Merge {base} into it, fix them and push."
Inbox: the events service tells people what needs them as events arrive2145 ),
2146 ));
2147 }
2148
2149 // A repository that merges through a queue: it joins the queue, and
2150 // lands once its state together with everything ahead has passed.
2151 if settings.merge_queue {
2152 if !a.actor.verified {
2153 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
2154 }
2155 check!(allowed(Some(&a.actor), &repo, Capability::Merge));
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2156 self.record_merge_evaluations(&repo, &pull, &gate).await;
Inbox: the events service tells people what needs them as events arrive2157 return self.enqueue(&repo, &pull, &a.actor, a.keep_issue_open).await;
2158 }
2159
2160 // The default branch has moved under it. Unless the repository
2161 // insists on that being dealt with first, bring it up to date and
2162 // land it when that is done.
2163 if self.is_behind(&repo.id, &pull).await? {
2164 if settings.require_up_to_date {
2165 return Ok(Outcome::fail(
2166 FailureCode::Conflict,
2167 format!(
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2168 "{base} has moved since this pull request was made, and this repository requires pull requests to be up to date before they merge. Catch up with {base} first."
Inbox: the events service tells people what needs them as events arrive2169 ),
2170 ));
2171 }
2172 if !a.actor.verified {
2173 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
2174 }
2175 check!(allowed(Some(&a.actor), &repo, Capability::Merge));
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2176 self.record_merge_evaluations(&repo, &pull, &gate).await;
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.2177 // Brought up to date here, without a sandbox: the merge is
2178 // recorded first, and lands when the push that catches it up
2179 // arrives (lifecycle.rs `land_if_requested`).
Inbox: the events service tells people what needs them as events arrive2180 self.request_landing(&pull, &a.actor, a.keep_issue_open)
2181 .await?;
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.2182 let updated: Outcome<PullBranchUpdate> = g1t_kit::call(
2183 &self.repos,
2184 "update_pull_branch",
2185 &UpdatePullBranchArgs {
2186 source_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.id.clone()),
2187 branch: pull.branch.clone(),
2188 number: pull.number,
2189 actor: a.actor.clone(),
2190 target_branch: pull.base.clone(),
2191 },
2192 )
2193 .await?;
2194 match updated {
2195 Outcome::Ok(PullBranchUpdate::Updated { .. }) => return Ok(Outcome::Ok(pull)),
2196 // Moved again in between, or already caught up: land it now.
2197 Outcome::Ok(PullBranchUpdate::UpToDate { .. }) => self.forget_landing(&pull.id).await?,
2198 Outcome::Ok(PullBranchUpdate::NeedsAgent { paths, .. }) => {
2199 self.forget_landing(&pull.id).await?;
2200 let named = if paths.is_empty() { String::new() } else { format!(" ({})", paths.join(", ")) };
2201 return Ok(Outcome::fail(
2202 FailureCode::Conflict,
2203 format!(
2204 "{base} has moved since this pull request was made, and both changed the same files{named}. Merge {base} into it, resolve any conflicts, push, and merge again."
2205 ),
2206 ));
2207 }
2208 Outcome::Fail(failure) => {
2209 self.forget_landing(&pull.id).await?;
2210 return Ok(Outcome::Fail(failure));
2211 }
2212 }
Inbox: the events service tells people what needs them as events arrive2213 }
2214
2215 // Whether the actor may write to the repository is decided by repos.
2216 let landed: Outcome<Landed> = g1t_kit::call(
2217 &self.repos,
2218 "land",
2219 &LandArgs {
2220 // A pull request from a branch lands from the repository itself.
2221 source_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.id.clone()),
2222 branch: pull.branch.clone(),
2223 actor: a.actor.clone(),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2224 target_branch: Some(base.clone()),
Inbox: the events service tells people what needs them as events arrive2225 },
2226 )
2227 .await?;
2228 let landed = check!(landed);
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2229 self.record_merge_evaluations(&repo, &pull, &gate).await;
Inbox: the events service tells people what needs them as events arrive2230 Ok(Outcome::Ok(
2231 self.record_merge(&repo, pull, &a.actor, a.keep_issue_open, landed)
2232 .await?,
2233 ))
2234 }
2235
2236 /// Records a pull request as merged once the default branch holds it:
2237 /// closes its issue, supersedes the others for it, and says so.
2238 pub(crate) async fn record_merge(
2239 &self,
2240 repo: &Repo,
2241 mut pull: Pull,
2242 actor: &User,
2243 keep_issue_open: bool,
2244 landed: Landed,
2245 ) -> Result<Pull> {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2246 // Only a merge into the default branch resolves the issue: into
2247 // another branch, the work has not landed yet.
2248 let keep_issue_open = keep_issue_open || !pull.targets_default(&repo.default_branch);
Inbox: the events service tells people what needs them as events arrive2249 let issue = match pull.issue {
2250 Some(number) if !keep_issue_open => self
2251 .issue(&repo.id, number)
2252 .await?
2253 .filter(|issue| issue.state == State::Open),
2254 _ => None,
2255 };
2256 let now = rfc3339(now_ms());
2257 let mut statements = vec![
2258 self.db
2259 .prepare(
2260 "UPDATE pulls
2261 SET status = 'merged', head_commit = ?, merge_base = ?, merged_by = ?,
2262 merged_at = ?, updated_at = ?
2263 WHERE id = ?",
2264 )
2265 .bind(&[
2266 landed.commit.as_str().into(),
2267 optional(&landed.previous),
2268 actor.username.as_str().into(),
2269 now.as_str().into(),
2270 now.as_str().into(),
2271 pull.id.as_str().into(),
2272 ])?,
2273 ];
2274 if let Some(issue) = &issue {
2275 statements.push(
2276 self.db
2277 .prepare(
2278 "UPDATE issues
2279 SET state = 'closed', reason = 'completed', resolved_by = ?,
2280 closed_at = ?, updated_at = ?
2281 WHERE id = ?",
2282 )
2283 .bind(&[
2284 pull.number.into(),
2285 now.as_str().into(),
2286 now.as_str().into(),
2287 issue.id.as_str().into(),
2288 ])?,
2289 );
2290 statements.push(
2291 self.db
2292 .prepare(
2293 "UPDATE pulls SET status = 'closed', superseded_by = ?, updated_at = ?
2294 WHERE issue_id = ? AND id != ? AND status IN ('draft', 'open')",
2295 )
2296 .bind(&[
2297 pull.number.into(),
2298 now.as_str().into(),
2299 issue.id.as_str().into(),
2300 pull.id.as_str().into(),
2301 ])?,
2302 );
2303 }
2304 self.db.batch(statements).await?;
2305
2306 self.publish(
2307 "pull.merged",
2308 &repo.id,
2309 actor,
2310 PullEvent {
2311 commit: Some(landed.commit.clone()),
2312 ..Self::pull_event(&pull)
2313 },
2314 )
2315 .await?;
2316 if let Some(issue) = &issue {
2317 self.publish(
2318 "issue.closed",
2319 &repo.id,
2320 actor,
2321 IssueEvent {
2322 reason: Some(IssueReason::Completed.as_str()),
2323 resolved_by: Some(pull.number),
2324 ..Self::issue_event(issue)
2325 },
2326 )
2327 .await?;
2328 }
2329
2330 let who = (actor.id.as_str(), actor.username.as_str());
2331 self.note(&repo.id, pull.number, who, "merged this").await?;
2332 if let Some(issue) = &issue {
2333 self.note(
2334 &repo.id,
2335 issue.number,
2336 who,
2337 &format!("closed this by merging #{}", pull.number),
2338 )
2339 .await?;
2340 }
Merge branch 'worktree-agent-ab8033ab973f8d99a'2341 let through_queue = self.landed_through_queue(&pull.id).await?;
2342 self.keep_decision_quietly(
2343 &repo.id,
2344 decisions::NewDecision {
2345 issue: pull.issue,
2346 pull: Some(pull.number),
2347 session_id: None,
2348 actor: (!decisions::is_g1t(actor)).then_some(actor),
2349 kind: g1t_contracts::decisions::DecisionKind::Merge,
2350 confidence: decisions::merge_confidence(through_queue, actor),
2351 summary: if through_queue {
2352 format!("#{} landed through the merge queue.", pull.number)
2353 } else {
2354 format!("{} merged #{}.", actor.username, pull.number)
2355 },
2356 evidence: serde_json::json!({
2357 "commit": landed.commit,
2358 "previous": landed.previous,
2359 "merge_queue": through_queue,
2360 }),
2361 },
2362 )
2363 .await;
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'2364 // A merge into the default branch clears its issue for the issues
2365 // it blocks, and ends the claims on it when it closed it.
2366 if let Some(number) = pull.issue
2367 && pull.targets_default(&repo.default_branch)
2368 {
2369 self.issue_resolved(&repo.id, number, actor, issue.is_some(), Some(pull.number)).await;
2370 }
Inbox: the events service tells people what needs them as events arrive2371 pull.status = PullStatus::Merged;
2372 pull.head_commit = Some(landed.commit.clone());
2373 pull.merge_base = landed.previous;
2374 pull.merged_by = Some(actor.username.clone());
2375 pull.merged_at = Some(now.clone());
2376 pull.updated_at = now;
2377 Ok(pull)
2378 }
2379
2380 async fn list_active_pulls(&self, a: ViewerArgs) -> Result<Vec<ActivePull>> {
2381 let Some(viewer) = a.viewer else {
2382 return Ok(Vec::new());
2383 };
2384 // The pull requests and their issues, in one round trip: their own,
2385 // and those g1t made for them (Pull::owner).
2386 let author = [JsValue::from(viewer.id.as_str())];
2387 let found = self
2388 .timing
2389 .db(
2390 2,
2391 self.db.batch(vec![
2392 self.db
2393 .prepare(format!(
2394 "SELECT {PULL_COLUMNS} FROM pulls
2395 WHERE COALESCE(requested_by_id, author_id) = ?1 AND status IN ('draft', 'open')
2396 ORDER BY updated_at DESC LIMIT 50"
2397 ))
2398 .bind(&author)?,
2399 self.db
2400 .prepare(format!(
2401 "SELECT {ISSUE_COLUMNS} FROM issues WHERE issues.id IN (
2402 SELECT issue_id FROM pulls
2403 WHERE COALESCE(requested_by_id, author_id) = ?1 AND status IN ('draft', 'open') AND issue_id IS NOT NULL
2404 ORDER BY updated_at DESC LIMIT 50)"
2405 ))
2406 .bind(&author)?,
2407 ]),
2408 )
2409 .await?;
2410 let (Some(found), Some(issues)) = (found.first(), found.get(1)) else {
2411 return Ok(Vec::new());
2412 };
2413 let snapshots = found.results::<Snapshot>()?;
2414 let pulls: Vec<Pull> = found.results::<PullRow>()?.into_iter().map(Pull::from).collect();
2415 let issues: Vec<Issue> = issues.results::<IssueRow>()?.into_iter().map(Issue::from).collect();
2416 let issues = &issues;
2417 // Where each stands: the remembered assessment when there is one,
2418 // and worked out otherwise.
2419 try_join_all(pulls.into_iter().zip(snapshots).map(|(pull, snapshot)| async move {
2420 let issue = pull.issue.and_then(|number| {
2421 issues
2422 .iter()
2423 .find(|issue| issue.repo_id == pull.repo_id && issue.number == number)
2424 .cloned()
2425 });
2426 // Only a pull request g1t is seeing through has a lifecycle.
2427 let lifecycle = if !lifecycle::made_by_g1t(&pull) || snapshot.managed == 0 {
2428 None
2429 } else if let (Some(stage), Some(detail)) = (snapshot.stage, snapshot.stage_detail) {
2430 Some(Lifecycle {
2431 stage,
2432 detail,
2433 revisions: snapshot.revisions,
2434 })
2435 } else {
2436 let behind = self.is_behind(&pull.repo_id, &pull).await?;
2437 self.assess(&pull, &issue, behind)
2438 .await?
2439 .map(|(lifecycle, _)| lifecycle)
2440 };
2441 Ok::<_, worker::Error>(ActivePull {
2442 pull,
2443 issue,
2444 lifecycle,
2445 })
2446 }))
2447 .await
2448 }
2449
2450 // --- Sessions ----------------------------------------------------------
2451
2452 async fn append_session(&self, a: AppendSessionArgs) -> Result<Outcome<Appended>> {
2453 if a.entries.is_empty() {
2454 return Ok(Outcome::Ok(Appended { count: 0 }));
2455 }
2456 if a.entries.len() > MAX_ENTRY_BATCH {
2457 return Ok(Outcome::fail(
2458 FailureCode::Invalid,
2459 format!("Send at most {MAX_ENTRY_BATCH} entries at a time."),
2460 ));
2461 }
2462 let viewer = Some(a.actor.clone());
2463 let (_, pull) = check!(self.pull_at(&a.repo, a.number, &viewer).await?);
2464 if !pull.is_owned_by(&a.actor.id) {
2465 return Ok(Outcome::fail(
2466 FailureCode::Forbidden,
2467 "Only whoever opened a pull request, or asked g1t for it, can record its session.",
2468 ));
2469 }
2470
2471 let now = rfc3339(now_ms());
2472 let count = a.entries.len() as u32;
2473 let mut statements = Vec::with_capacity(a.entries.len() + 1);
2474 for entry in a.entries {
2475 let kind = serde_json::to_value(entry.kind)?;
2476 let text: String = entry.text.chars().take(MAX_ENTRY_CHARS).collect();
2477 // Each insert takes the next sequence number itself, so two
2478 // writers appending at once cannot collide.
2479 statements.push(
2480 self.db
2481 .prepare(
2482 "INSERT INTO session_entries (pull_id, seq, kind, text, tool, \"commit\", at)
2483 SELECT ?, COALESCE(MAX(seq), 0) + 1, ?, ?, ?, ?, ?
2484 FROM session_entries WHERE pull_id = ?",
2485 )
2486 .bind(&[
2487 pull.id.as_str().into(),
2488 kind.as_str().unwrap_or("note").into(),
2489 text.into(),
2490 optional(&entry.tool),
2491 optional(&entry.commit.or_else(|| pull.head_commit.clone())),
2492 now.as_str().into(),
2493 pull.id.as_str().into(),
2494 ])?,
2495 );
2496 }
2497 statements.push(
2498 self.db
2499 .prepare("UPDATE pulls SET updated_at = ? WHERE id = ?")
2500 .bind(&[now.as_str().into(), pull.id.as_str().into()])?,
2501 );
2502 self.db.batch(statements).await?;
2503 self.publish(
2504 "session.appended",
2505 &pull.repo_id,
2506 &a.actor,
2507 SessionAppended {
2508 pull_id: pull.id.clone(),
2509 repo_id: pull.repo_id.clone(),
2510 number: pull.number,
2511 count,
2512 },
2513 )
2514 .await?;
2515 Ok(Outcome::Ok(Appended { count }))
2516 }
2517
2518 async fn read_session(&self, a: ViewArgs) -> Result<Outcome<Vec<SessionEntry>>> {
2519 let (_, pull) = check!(self.pull_at(&a.repo, a.number, &a.viewer).await?);
2520 let rows = self
2521 .db
2522 .prepare(
2523 "SELECT seq, kind, text, tool, \"commit\", at FROM session_entries
2524 WHERE pull_id = ? AND seq > ? ORDER BY seq LIMIT ?",
2525 )
2526 .bind(&[pull.id.into(), a.after_seq.into(), SESSION_PAGE.into()])?
2527 .all()
2528 .await?
2529 .results::<SessionRow>()?;
2530 Ok(Outcome::Ok(
2531 rows.into_iter().map(SessionEntry::from).collect(),
2532 ))
2533 }
2534
2535 /// A push moves the head of the pull request it concerns: the one whose
2536 /// fork was pushed to, or the one opened from the branch that moved.
2537 async fn on_event(&self, event: &Event) -> Result<()> {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2538 // A new repository starts with the default labels.
2539 if event.kind == "repo.created"
2540 && let Some(repo_id) = event.repo_id.as_deref()
2541 {
2542 return self.seed_labels(repo_id).await;
2543 }
2544 // Pull requests into the branch that became the default merge into
2545 // the default branch, which is stored as none.
2546 if event.kind == "repo.default_branch_changed"
2547 && let (Some(repo_id), Some(to)) = (event.repo_id.as_deref(), event.data["to"].as_str())
2548 {
2549 self.db
2550 .prepare(
2551 "UPDATE pulls SET base_branch = NULL
2552 WHERE repo_id = ? AND base_branch = ? AND status IN ('draft', 'open')",
2553 )
2554 .bind(&[repo_id.into(), to.into()])?
2555 .run()
2556 .await?;
2557 return Ok(());
2558 }
Inbox: the events service tells people what needs them as events arrive2559 if event.kind != "git.push" {
2560 return Ok(());
2561 }
2562 let (Some(repo_id), Some(after), Some(git_ref)) = (
2563 event.repo_id.as_deref(),
2564 event.data["after"].as_str(),
2565 event.data["ref"].as_str(),
2566 ) else {
2567 return Ok(());
2568 };
2569 let now = rfc3339(now_ms());
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2570 // Who moved it, for rules about the most recent push.
2571 let pusher: JsValue = event.actor.as_deref().map_or(JsValue::NULL, Into::into);
Inbox: the events service tells people what needs them as events arrive2572 // The head moved, so whatever the checks said no longer applies, and
2573 // whatever step g1t was waiting on has been taken.
2574 let moved = "UPDATE pulls
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2575 SET head_commit = ?, updated_at = ?, head_pushed_by = ?, head_pushed_at = ?, check_status = NULL, check_run_id = NULL,
Inbox: the events service tells people what needs them as events arrive2576 working_on = NULL, working_until = NULL, stalled = NULL";
2577 let active = "status IN ('draft', 'open') AND head_commit IS NOT ?";
2578 let returning =
2579 "RETURNING id, repo_id, number, issue_number, status, author_id, author_name, requested_by_id, requested_by_name";
2580 let mut pulls: Vec<MovedRow> = Vec::new();
2581 // A fork carries its pull request on its default branch.
2582 if event.data["defaultBranch"].as_bool() == Some(true) {
2583 pulls.extend(
2584 self.db
2585 .prepare(format!(
2586 "{moved} WHERE fork_repo_id = ? AND {active} {returning}"
2587 ))
2588 .bind(&[
2589 after.into(),
2590 now.as_str().into(),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2591 pusher.clone(),
2592 now.as_str().into(),
Inbox: the events service tells people what needs them as events arrive2593 repo_id.into(),
2594 after.into(),
2595 ])?
2596 .all()
2597 .await?
2598 .results::<MovedRow>()?,
2599 );
2600 }
2601 if let Some(branch) = git_ref.strip_prefix("refs/heads/") {
2602 pulls.extend(
2603 self.db
2604 .prepare(format!(
2605 "{moved} WHERE repo_id = ? AND source_branch = ? AND {active} {returning}"
2606 ))
2607 .bind(&[
2608 after.into(),
2609 now.as_str().into(),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2610 pusher.clone(),
2611 now.as_str().into(),
Inbox: the events service tells people what needs them as events arrive2612 repo_id.into(),
2613 branch.into(),
2614 after.into(),
2615 ])?
2616 .all()
2617 .await?
2618 .results::<MovedRow>()?,
2619 );
2620 }
2621 // What each now changes, so overlaps show while the work is under way.
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.2622 let mut moved_pulls = Vec::new();
Inbox: the events service tells people what needs them as events arrive2623 for moved in &pulls {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2624 if let Some(mut pull) = self.pull_by_id(&moved.id).await? {
2625 pull.files = self.refresh_files(&pull).await?;
2626 // Owners of files it now changes are asked too.
2627 self.refresh_code_owners(&pull).await;
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'2628 // A new overlap with another pull request is announced.
2629 self.announce_overlaps(&pull, event.actor.clone()).await;
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.2630 moved_pulls.push(pull);
Inbox: the events service tells people what needs them as events arrive2631 }
2632 }
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.2633 // The harness sessions that pushed the branch, or that its new
2634 // commits name in trailers, are tied to its pull requests.
2635 if let Some(branch) = git_ref.strip_prefix("refs/heads/") {
2636 let commits = event.data["commits"].as_u64().and_then(|n| u32::try_from(n).ok());
2637 self.link_push(repo_id, branch, after, commits, &moved_pulls).await;
2638 }
Inbox: the events service tells people what needs them as events arrive2639 // A merge that was waiting for this push to bring it up to date.
2640 for moved in &pulls {
2641 self.land_if_requested(&moved.id).await?;
2642 }
2643 // Whether each still merges cleanly, and, when a default branch
2644 // moved, every open pull request into it.
2645 let moved_ids: Vec<String> = pulls.iter().map(|pull| pull.id.clone()).collect();
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2646 let into = match event.data["defaultBranch"].as_bool() {
2647 Some(true) => mergeability::Moved::DefaultBranch,
2648 _ => match git_ref.strip_prefix("refs/heads/") {
2649 Some(branch) => mergeability::Moved::Branch(branch),
2650 None => mergeability::Moved::Nothing,
2651 },
2652 };
2653 self.after_push(repo_id, into, &moved_ids).await;
Inbox: the events service tells people what needs them as events arrive2654 // A draft is announced when it is marked ready instead.
2655 for pull in pulls
2656 .into_iter()
2657 .filter(|pull| pull.status == PullStatus::Open)
2658 {
2659 self.publish_as(
2660 "pull.updated",
2661 &pull.repo_id,
2662 event.actor.clone(),
2663 PullEvent {
2664 author: Some(g1t_contracts::credentials::Principal { id: pull.author_id, username: pull.author_name }),
2665 requested_by: pull
2666 .requested_by_id
2667 .zip(pull.requested_by_name)
2668 .map(|(id, username)| g1t_contracts::credentials::Principal { id, username }),
2669 pull_id: pull.id,
2670 repo_id: pull.repo_id.clone(),
2671 number: pull.number,
2672 issue: pull.issue_number,
2673 commit: Some(after.to_owned()),
2674 ..PullEvent::default()
Merge branch 'worktree-agent-a3abfcce648e87dca'2675 }
2676 .carrying(&event.data),
Inbox: the events service tells people what needs them as events arrive2677 )
2678 .await?;
2679 }
2680 Ok(())
2681 }
2682}
2683
Merge branch 'worktree-agent-a3abfcce648e87dca'2684/// An event's data that carries on what caused the event it follows from.
2685trait Carrying: serde::Serialize + Sized {
2686 /// As JSON, marked as a workflow job's doing when `cause` was.
2687 fn carrying(self, cause: &serde_json::Value) -> serde_json::Value {
2688 g1t_contracts::events::carried(self, cause)
2689 }
2690}
2691
2692impl Carrying for PullEvent {}
2693
Inbox: the events service tells people what needs them as events arrive2694fn service(env: &Env) -> Result<Work> {
2695 Ok(Work {
2696 db: env.d1("DB")?,
2697 identity: env.service("IDENTITY")?,
2698 repos: env.service("REPOS")?,
2699 events: env.service("EVENTS")?,
2700 actions: env.service("ACTIONS")?,
2701 timing: g1t_kit::d1::Timing::default(),
2702 prefetched: std::cell::RefCell::new(None),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2703 known_repos: std::cell::RefCell::new(std::collections::HashMap::new()),
Inbox: the events service tells people what needs them as events arrive2704 })
2705}
2706
2707#[event(fetch)]
2708async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
2709 let Some(method) = rpc_method(&request) else {
2710 return Response::error("Not found", 404);
2711 };
2712 // A replica near the caller when it asks for one (crates/kit/src/d1.rs).
2713 let (db, served) = g1t_kit::d1::open(&env, "DB", &request)?;
2714 let body: serde_json::Value = request.json().await?;
2715 let mut work = service(&env)?;
2716 work.db = db;
2717
2718 let answered = match method.as_str() {
2719 "open_issue" => reply(&work.open_issue(args(body)?).await?),
2720 "list_issues" => reply(&work.list_issues(args(body)?).await?),
2721 "get_issue" => reply(&work.get_issue(args(body)?).await?),
2722 "update_issue" => reply(&work.update_issue(args(body)?).await?),
2723 "close_issue" => reply(&work.close_issue(args(body)?).await?),
2724 "reopen_issue" => reply(&work.reopen_issue(args(body)?).await?),
2725 "list_labels" => reply(&work.list_labels(args(body)?).await?),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2726 "save_label" => reply(&work.save_label(args(body)?).await?),
2727 "delete_label" => reply(&work.delete_label(args(body)?).await?),
2728 "add_default_labels" => reply(&work.add_default_labels(args(body)?).await?),
2729 "set_labels" => reply(&work.set_labels(args(body)?).await?),
2730 "list_milestones" => reply(&work.list_milestones(args(body)?).await?),
2731 "get_milestone" => reply(&work.get_milestone(args(body)?).await?),
2732 "save_milestone" => reply(&work.save_milestone(args(body)?).await?),
2733 "delete_milestone" => reply(&work.delete_milestone(args(body)?).await?),
Inbox: the events service tells people what needs them as events arrive2734 "counts" => reply(&work.counts(args(body)?).await?),
2735 "add_comment" => reply(&work.add_comment(args(body)?).await?),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts2736 "edit_comment" => reply(&work.edit_comment(args(body)?).await?),
2737 "delete_comment" => reply(&work.delete_comment(args(body)?).await?),
Inbox: the events service tells people what needs them as events arrive2738 "start_checks" => reply(&work.start_checks(args(body)?).await?),
2739 "seen_checks" => reply(&work.seen_checks(args(body)?).await?),
2740 "report_checks" => reply(&work.report_checks(args(body)?).await?),
2741 "set_commit_status" => reply(&work.set_commit_status(args(body)?).await?),
Merge checks: statuses and check runs on every commit2742 "create_commit_status" => reply(&work.create_commit_status(args(body)?).await?),
2743 "commit_statuses" => reply(&work.commit_statuses(args(body)?).await?),
2744 "combined_status" => reply(&work.combined_status(args(body)?).await?),
2745 "create_check_run" => reply(&work.create_check_run(args(body)?).await?),
2746 "update_check_run" => reply(&work.update_check_run(args(body)?).await?),
2747 "get_check_run" => reply(&work.get_check_run(args(body)?).await?),
2748 "check_run_annotations" => reply(&work.check_run_annotations(args(body)?).await?),
2749 "ref_check_runs" => reply(&work.ref_check_runs(args(body)?).await?),
2750 "ref_check_suites" => reply(&work.ref_check_suites(args(body)?).await?),
2751 "get_check_suite" => reply(&work.get_check_suite(args(body)?).await?),
2752 "rerequest_check_run" => reply(&work.rerequest_check_run(args(body)?).await?),
2753 "rerequest_check_suite" => reply(&work.rerequest_check_suite(args(body)?).await?),
2754 "request_check_action" => reply(&work.request_check_action(args(body)?).await?),
2755 "commit_checks" => reply(&work.commit_checks(args(body)?).await?),
Inbox: the events service tells people what needs them as events arrive2756 "advance" => reply(&work.advance(args(body)?).await?),
2757 "stall" => reply(&work.stall(args(body)?).await?),
2758 "managed_pulls" => reply(&work.managed_pulls(args(body)?).await?),
2759 "queue" => reply(&work.queue(args(body)?).await?),
2760 "queue_build" => reply(&work.queue_build(args(body)?).await?),
2761 "report_queue" => reply(&work.report_queue(args(body)?).await?),
2762 "remove_from_queue" => reply(&work.remove_from_queue(args(body)?).await?),
2763 "locate_pull" => reply(&work.locate_pull(args(body)?).await?),
2764 "inbox_subject" => reply(&work.inbox_subject(args(body)?).await?),
2765 "get_settings" => reply(&work.get_settings(args(body)?).await?),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2766 "codeowners_errors" => reply(&work.codeowners_errors(args(body)?).await?),
Inbox: the events service tells people what needs them as events arrive2767 "update_settings" => reply(&work.update_settings(args(body)?).await?),
2768 "open_pull" => reply(&work.open_pull(args(body)?).await?),
2769 "list_pulls" => reply(&work.list_pulls(args(body)?).await?),
2770 "pulls_for_repos" => reply(&work.pulls_for_repos(args(body)?).await?),
2771 "get_pull" => reply(&work.get_pull(args(body)?).await?),
2772 "update_pull" => reply(&work.update_pull(args(body)?).await?),
2773 "catch_up_pull" => reply(&work.catch_up_pull(args(body)?).await?),
2774 "ready_pull" => reply(&work.ready_pull(args(body)?).await?),
2775 "close_pull" => reply(&work.close_pull(args(body)?).await?),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts2776 "reopen_pull" => reply(&work.reopen_pull(args(body)?).await?),
2777 "convert_pull_to_draft" => reply(&work.convert_pull_to_draft(args(body)?).await?),
Inbox: the events service tells people what needs them as events arrive2778 "merge_pull" => reply(&work.merge_pull(args(body)?).await?),
2779 "list_active_pulls" => reply(&work.list_active_pulls(args(body)?).await?),
2780 "by_author" => reply(&work.by_author(args(body)?).await?),
Chat controls, public profiles, shadcn selects, and no Docs tab in a project2781 "contributions" => reply(&work.contributions(args(body)?).await?),
Inbox: the events service tells people what needs them as events arrive2782 "list_assigned_issues" => reply(&work.list_assigned_issues(args(body)?).await?),
2783 "append_session" => reply(&work.append_session(args(body)?).await?),
2784 "read_session" => reply(&work.read_session(args(body)?).await?),
Merge branch 'worktree-agent-ab8033ab973f8d99a'2785 // Decision records (decisions.rs).
2786 "record_decision" => reply(&work.record_decision(args(body)?).await?),
2787 "list_decisions" => reply(&work.list_decisions(args(body)?).await?),
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'2788 // Coordinating work: harness sessions, claims, dependencies and an
2789 // issue's whole context (coordination/).
2790 "start_session" => reply(&work.start_session(args(body)?).await?),
2791 "update_session" => reply(&work.update_session(args(body)?).await?),
2792 "end_session" => reply(&work.end_session(args(body)?).await?),
2793 "get_work_session" => reply(&work.get_work_session(args(body)?).await?),
2794 "list_work_sessions" => reply(&work.list_work_sessions(args(body)?).await?),
2795 "record_session_entries" => reply(&work.record_session_entries(args(body)?).await?),
2796 "claim_issue" => reply(&work.claim_issue(args(body)?).await?),
2797 "release_claim" => reply(&work.release_claim(args(body)?).await?),
2798 "hand_off_issue" => reply(&work.hand_off_issue(args(body)?).await?),
2799 "list_claims" => reply(&work.list_claims(args(body)?).await?),
2800 "get_work" => reply(&work.get_work(args(body)?).await?),
Memory is gone: people's own harnesses keep their own memory (CLAUDE.md, AGENTS.md, auto-memory), so g1t's project and workspace notes that agents recalled and remembered only duplicated them. The recall and remember operations, their /repos/{owner}/{name}/memory routes, the memory MCP tool and its lines in the MCP instructions, the Memory section of the API reference and OpenAPI document (and the CLI's table generated from it) are removed, as are the work service's memory methods, the Memory pages of a project and of a workspace with their sidebar links, Home's Decisions card (which read decisions out of workspace memory), the Memory guide (its address and the old agent-memory, agents-and-memory and context-hub ones now lead to Connect a coding agent) and every mention in the docs, README, llms.txt, the landing page, the privacy policy and the Ask bench. Decision records, g1t why and g1t work stay as they were. memory:read and memory:write are retired as agents:run and the models scopes were: tokens and OAuth grants that hold them still parse and keep working for their other scopes, but no preset (the Agent preset and the OAuth default included), token form, mobile sign-in or hosted session hands them out and nothing needs them; looking up a ticket (search ticket, get_context), which needed memory:read, needs issues:read. The memories table stays in work's D1 untouched and is no longer read or written, except that purging a repository or deleting a workspace still deletes its old rows. Checked: cargo test for g1t-contracts (308), g1t-api (201), g1t (102 and 41), g1t-work (103) and g1t-integrations (65), cargo check of the workspace, the OpenAPI document and ops table regenerated, every workspace's typecheck, the site's 901 tests and a production build, the contracts' 48 tests, the phone app's 11, the deploy tool's 92, and the docs' tests and build.2801 // Runs of sandboxes (runs.rs).
Inbox: the events service tells people what needs them as events arrive2802 "open_run" => reply(&work.open_run(args(body)?).await?),
2803 "report_run" => reply(&work.report_run(args(body)?).await?),
2804 "list_runs" => reply(&work.list_runs(args(body)?).await?),
2805 "get_run" => reply(&work.get_run(args(body)?).await?),
The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens.2806 "list_sessions" => reply(&work.list_sessions(args(body)?).await?),
2807 "get_session" => reply(&work.get_session(args(body)?).await?),
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.2808 // What a sandbox may reach (guardrails.rs).
Inbox: the events service tells people what needs them as events arrive2809 "get_guardrails" => reply(&work.get_guardrails(args(body)?).await?),
2810 "update_guardrails" => reply(&work.update_guardrails(args(body)?).await?),
2811 "run_guardrails" => reply(&work.run_guardrails(args(body)?).await?),
2812 // The runs whose sandboxes stop with their repository (retired.rs).
2813 "runs_in_repo" => reply(&work.runs_in_repo(args(body)?).await?),
2814 "start_mergecheck" => reply(&work.start_mergecheck(args(body)?).await?),
2815 "report_mergecheck" => reply(&work.report_mergecheck(args(body)?).await?),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2816 // Rulesets (rulesets.rs): kept here, enforced here on merge and by
2817 // repos on push.
2818 "list_rulesets" => reply(&work.list_rulesets(args(body)?).await?),
2819 "get_ruleset" => reply(&work.get_ruleset(args(body)?).await?),
2820 "save_ruleset" => reply(&work.save_ruleset(args(body)?).await?),
2821 "delete_ruleset" => reply(&work.delete_ruleset(args(body)?).await?),
2822 "effective_rules" => reply(&work.effective_rules(args(body)?).await?),
2823 "rule_evaluations" => reply(&work.rule_evaluations(args(body)?).await?),
2824 "ref_rules" => reply(&work.ref_rules(args(body)?).await?),
2825 "record_evaluations" => reply(&work.record_evaluations(args(body)?).await?),
2826 "set_requires_pull_request" => reply(&work.set_requires_pull_request(args(body)?).await?),
Inbox: the events service tells people what needs them as events arrive2827 _ => Response::error("Unknown method", 404),
2828 };
2829 served.finish_timed(answered, &work.timing)
2830}
2831
2832/// Events from the bus, delivered on this service's own queue.
2833#[event(queue)]
2834async fn queue(batch: MessageBatch<Event>, env: Env, _ctx: Context) -> Result<()> {
2835 let work = service(&env)?;
2836 for message in batch.messages()? {
Memory is gone: people's own harnesses keep their own memory (CLAUDE.md, AGENTS.md, auto-memory), so g1t's project and workspace notes that agents recalled and remembered only duplicated them. The recall and remember operations, their /repos/{owner}/{name}/memory routes, the memory MCP tool and its lines in the MCP instructions, the Memory section of the API reference and OpenAPI document (and the CLI's table generated from it) are removed, as are the work service's memory methods, the Memory pages of a project and of a workspace with their sidebar links, Home's Decisions card (which read decisions out of workspace memory), the Memory guide (its address and the old agent-memory, agents-and-memory and context-hub ones now lead to Connect a coding agent) and every mention in the docs, README, llms.txt, the landing page, the privacy policy and the Ask bench. Decision records, g1t why and g1t work stay as they were. memory:read and memory:write are retired as agents:run and the models scopes were: tokens and OAuth grants that hold them still parse and keep working for their other scopes, but no preset (the Agent preset and the OAuth default included), token form, mobile sign-in or hosted session hands them out and nothing needs them; looking up a ticket (search ticket, get_context), which needed memory:read, needs issues:read. The memories table stays in work's D1 untouched and is no longer read or written, except that purging a repository or deleting a workspace still deletes its old rows. Checked: cargo test for g1t-contracts (308), g1t-api (201), g1t (102 and 41), g1t-work (103) and g1t-integrations (65), cargo check of the workspace, the OpenAPI document and ops table regenerated, every workspace's typecheck, the site's 901 tests and a production build, the contracts' 48 tests, the phone app's 11, the deploy tool's 92, and the docs' tests and build.2837 // A workspace renamed: its runs move to the slug it has now.
2838 if g1t_kit::rename::on_event(&env, &env.d1("DB")?, message.body(), &[runs::RENAMED, guardrails::RENAMED, rulesets::RENAMED].concat()).await? {
Inbox: the events service tells people what needs them as events arrive2839 message.ack();
2840 continue;
2841 }
Memory is gone: people's own harnesses keep their own memory (CLAUDE.md, AGENTS.md, auto-memory), so g1t's project and workspace notes that agents recalled and remembered only duplicated them. The recall and remember operations, their /repos/{owner}/{name}/memory routes, the memory MCP tool and its lines in the MCP instructions, the Memory section of the API reference and OpenAPI document (and the CLI's table generated from it) are removed, as are the work service's memory methods, the Memory pages of a project and of a workspace with their sidebar links, Home's Decisions card (which read decisions out of workspace memory), the Memory guide (its address and the old agent-memory, agents-and-memory and context-hub ones now lead to Connect a coding agent) and every mention in the docs, README, llms.txt, the landing page, the privacy policy and the Ask bench. Decision records, g1t why and g1t work stay as they were. memory:read and memory:write are retired as agents:run and the models scopes were: tokens and OAuth grants that hold them still parse and keep working for their other scopes, but no preset (the Agent preset and the OAuth default included), token form, mobile sign-in or hosted session hands them out and nothing needs them; looking up a ticket (search ticket, get_context), which needed memory:read, needs issues:read. The memories table stays in work's D1 untouched and is no longer read or written, except that purging a repository or deleting a workspace still deletes its old rows. Checked: cargo test for g1t-contracts (308), g1t-api (201), g1t (102 and 41), g1t-work (103) and g1t-integrations (65), cargo check of the workspace, the OpenAPI document and ops table regenerated, every workspace's typecheck, the site's 901 tests and a production build, the contracts' 48 tests, the phone app's 11, the deploy tool's 92, and the docs' tests and build.2842 // A repository renamed or transferred: its runs and guardrails
2843 // follow.
2844 if g1t_kit::transfer::on_event(&env, &env.d1("DB")?, message.body(), &[runs::TRANSFERRED, guardrails::TRANSFERRED].concat()).await? {
Inbox: the events service tells people what needs them as events arrive2845 message.ack();
2846 continue;
2847 }
2848 // A workspace deleted: what it kept for itself goes.
Memory is gone: people's own harnesses keep their own memory (CLAUDE.md, AGENTS.md, auto-memory), so g1t's project and workspace notes that agents recalled and remembered only duplicated them. The recall and remember operations, their /repos/{owner}/{name}/memory routes, the memory MCP tool and its lines in the MCP instructions, the Memory section of the API reference and OpenAPI document (and the CLI's table generated from it) are removed, as are the work service's memory methods, the Memory pages of a project and of a workspace with their sidebar links, Home's Decisions card (which read decisions out of workspace memory), the Memory guide (its address and the old agent-memory, agents-and-memory and context-hub ones now lead to Connect a coding agent) and every mention in the docs, README, llms.txt, the landing page, the privacy policy and the Ask bench. Decision records, g1t why and g1t work stay as they were. memory:read and memory:write are retired as agents:run and the models scopes were: tokens and OAuth grants that hold them still parse and keep working for their other scopes, but no preset (the Agent preset and the OAuth default included), token form, mobile sign-in or hosted session hands them out and nothing needs them; looking up a ticket (search ticket, get_context), which needed memory:read, needs issues:read. The memories table stays in work's D1 untouched and is no longer read or written, except that purging a repository or deleting a workspace still deletes its old rows. Checked: cargo test for g1t-contracts (308), g1t-api (201), g1t (102 and 41), g1t-work (103) and g1t-integrations (65), cargo check of the workspace, the OpenAPI document and ops table regenerated, every workspace's typecheck, the site's 901 tests and a production build, the contracts' 48 tests, the phone app's 11, the deploy tool's 92, and the docs' tests and build.2849 if g1t_kit::deleted::on_event(&env.d1("DB")?, message.body(), guardrails::DELETED).await? {
Inbox: the events service tells people what needs them as events arrive2850 message.ack();
2851 continue;
2852 }
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)2853 // An account purged: what it wrote shows as ghost (ghost.rs).
2854 let ghost = ghost::statements();
2855 let ghost: Vec<&str> = ghost.iter().map(String::as_str).collect();
2856 if g1t_kit::user_deleted::on_event(&env.d1("DB")?, message.body(), &ghost).await? {
2857 message.ack();
2858 continue;
2859 }
Inbox: the events service tells people what needs them as events arrive2860 // A repository deleted, archived or purged, or a branch renamed (retired.rs).
2861 if work.on_retired(message.body()).await? {
2862 message.ack();
2863 continue;
2864 }
2865 work.on_event(message.body()).await?;
2866 message.ack();
2867 }
2868 Ok(())
2869}
2870
2871/// The rules that once read a pull request's author read its owner now:
2872/// whoever asked g1t for it, or its author. For each, the person who asked
2873/// is held to what an author was, and g1t's agent (a token it works with)
2874/// gains nothing by being the author.
2875#[cfg(test)]
2876mod owner_rules {
2877 use super::*;
2878 use crate::rows::stored::{ASKER, G1T, pull};
2879 use g1t_contracts::identity::AGENT_ID;
2880
2881 const SOMEONE: &str = "usr_2";
2882
2883 #[test]
2884 fn no_self_approval() {
2885 // add_comment refuses a verdict on one that is theirs.
2886 let made = pull(G1T, Some(ASKER));
2887 assert!(made.is_owned_by(ASKER.0), "the person who asked cannot approve it");
2888 assert!(!made.is_owned_by(AGENT_ID), "g1t's review agent still gives its verdict");
2889 assert!(!made.is_owned_by(SOMEONE));
2890 }
2891
2892 #[test]
2893 fn what_an_author_could_do_without_a_role() {
2894 // manageable_pull (update, ready, close), catch_up_pull on a fork,
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.2895 // and append_session: theirs to do.
Inbox: the events service tells people what needs them as events arrive2896 let made = pull(G1T, Some(ASKER));
2897 assert!(made.is_owned_by(ASKER.0));
2898 assert!(!made.is_owned_by(SOMEONE), "anyone else still needs the role");
2899 assert!(!made.is_owned_by(AGENT_ID), "being its author gives g1t's tokens nothing more");
2900 }
2901
2902 #[test]
2903 fn nobody_is_asked_to_review_what_they_asked_for() {
2904 // update_pull drops the owner from the reviewers asked.
2905 let made = pull(G1T, Some(ASKER));
2906 let mut reviewers = vec!["syntaqx".to_owned(), "ana".to_owned()];
2907 reviewers.retain(|name| *name != made.owner().username);
2908 assert_eq!(reviewers, ["ana"]);
2909 }
2910
2911 #[test]
2912 fn sandboxes_act_as_whoever_asked() {
The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens.2913 // MergecheckJob and the merge queue's job
Inbox: the events service tells people what needs them as events arrive2914 // carry who the sandbox's credential acts for: a real account.
2915 let made = pull(G1T, Some(ASKER));
2916 assert_eq!(made.owner().id, ASKER.0);
2917 let acts_as = made.requested_by.unwrap_or(made.author);
2918 assert_eq!(acts_as.id, ASKER.0);
2919 // g1t's own work, which nobody asked for, acts as g1t, as before.
2920 let own = pull(("g1t", "g1t"), None);
2921 assert_eq!(own.requested_by.unwrap_or(own.author).id, "g1t");
2922 }
2923
2924 #[test]
2925 fn events_name_g1t_and_whoever_asked() {
2926 let made = pull(G1T, Some(ASKER));
2927 let event = serde_json::to_value(Work::pull_event(&made)).unwrap();
2928 assert_eq!(event["author"], serde_json::json!({ "id": AGENT_ID, "username": "g1t" }));
2929 assert_eq!(event["requestedBy"], serde_json::json!({ "id": "usr_1", "username": "syntaqx" }));
2930 let own = serde_json::to_value(Work::pull_event(&pull(ASKER, None))).unwrap();
2931 assert!(own.get("requestedBy").is_none());
2932 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts2933
2934 #[test]
2935 fn only_a_closed_pull_request_reopens_and_only_an_open_one_turns_draft() {
2936 assert_eq!(reopen_refusal(PullStatus::Closed), None);
2937 assert!(reopen_refusal(PullStatus::Merged).is_some(), "a merge cannot be undone");
2938 assert!(reopen_refusal(PullStatus::Open).is_some());
2939 assert!(reopen_refusal(PullStatus::Draft).is_some());
2940 assert_eq!(draft_refusal(PullStatus::Open), None);
2941 assert!(draft_refusal(PullStatus::Draft).is_some());
2942 assert!(draft_refusal(PullStatus::Closed).is_some());
2943 assert!(draft_refusal(PullStatus::Merged).is_some());
2944 }
2945
2946 #[test]
2947 fn a_pull_request_reopens_as_what_it_was_closed_as() {
2948 assert_eq!(reopened_status(Some("draft")), PullStatus::Draft);
2949 assert_eq!(reopened_status(Some("open")), PullStatus::Open);
2950 // Closed before this was recorded, or by a merge of another.
2951 assert_eq!(reopened_status(None), PullStatus::Open);
2952 }
Inbox: the events service tells people what needs them as events arrive2953}

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