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 arrive | 1 | //! 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 | ||
| 7 | mod authored; | |
| 8 | mod checks; | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 9 | mod ghost; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 10 | mod codeowners; |
| Merge checks: statuses and check runs on every commit | 11 | mod commit_checks; |
| Chat controls, public profiles, shadcn selects, and no Docs tab in a project | 12 | mod contributions; |
| Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae' | 13 | mod coordination; |
| Merge branch 'worktree-agent-ab8033ab973f8d99a' | 14 | mod decisions; |
| Inbox: the events service tells people what needs them as events arrive | 15 | mod guardrails; |
| 16 | mod inbox; | |
| Merge branch 'worktree-agent-ab8033ab973f8d99a' | 17 | mod integration; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 18 | mod labels; |
| Inbox: the events service tells people what needs them as events arrive | 19 | mod lifecycle; |
| 20 | mod mergeability; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 21 | mod milestones; |
| Inbox: the events service tells people what needs them as events arrive | 22 | mod prefetch; |
| 23 | mod queue; | |
| 24 | mod retired; | |
| 25 | mod reviews; | |
| 26 | mod rows; | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 27 | mod rulesets; |
| Inbox: the events service tells people what needs them as events arrive | 28 | mod runs; |
| 29 | mod settings; | |
| 30 | mod statuses; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 31 | mod team_reviews; |
| Inbox: the events service tells people what needs them as events arrive | 32 | |
| 33 | use g1t_contracts::events::{ | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 34 | ChangedFrom, CommentChanges, CommentCreated, CommentDeleted, CommentEdited, DeletedComment, Event, IssueEvent, |
| 35 | NewEvent, Publish, PullEvent, SessionAppended, | |
| Inbox: the events service tells people what needs them as events arrive | 36 | }; |
| 37 | use g1t_contracts::identity::UsernameArgs; | |
| 38 | use g1t_contracts::repos::{ | |
| 39 | ForkArgs, GetArgs, HeadArgs, LandArgs, Landed, NeedsAgentReason, PullBranchUpdate, ReadableArgs, Repo, RepoPath, | |
| 40 | UpdatePullBranchArgs, | |
| 41 | }; | |
| 42 | use g1t_contracts::access::{self, Capability, Denied}; | |
| 43 | use g1t_contracts::time::rfc3339; | |
| 44 | use g1t_contracts::work::*; | |
| 45 | use futures_util::future::{try_join, try_join3, try_join_all}; | |
| 46 | use g1t_contracts::{FailureCode, Outcome, User, Viewer, new_id}; | |
| 47 | use g1t_kit::{args, now_ms, reply, rpc_method}; | |
| 48 | use serde::Serialize; | |
| 49 | use worker::wasm_bindgen::JsValue; | |
| 50 | use worker::{ | |
| 51 | Context, D1Database, Env, Fetcher, MessageBatch, MessageExt, Request, Response, Result, event, | |
| 52 | }; | |
| 53 | ||
| 54 | use retired::writable; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 55 | use rows::{CommentRow, IssueRow, MovedRow, NumberRow, PULL_COLUMNS, PullRow, SessionRow, Snapshot}; |
| Inbox: the events service tells people what needs them as events arrive | 56 | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 57 | pub(crate) const SOURCE: &str = "work"; |
| Inbox: the events service tells people what needs them as events arrive | 58 | const MAX_ENTRY_BATCH: usize = 200; |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 59 | pub(crate) const MAX_ENTRY_CHARS: usize = 64_000; |
| Inbox: the events service tells people what needs them as events arrive | 60 | const MAX_TITLE_CHARS: usize = 200; |
| 61 | const SESSION_PAGE: u32 = 500; | |
| 62 | const LIST_PAGE: u32 = 100; | |
| 63 | const MAX_ASSIGNEES: usize = 10; | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 64 | pub(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 arrive | 65 | |
| 66 | const 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 bar | 74 | 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 arrive | 77 | |
| 78 | fn no_issue<T>() -> Outcome<T> { | |
| 79 | Outcome::fail(FailureCode::NotFound, "Issue not found.") | |
| 80 | } | |
| 81 | ||
| 82 | fn 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)] | |
| 89 | struct StoredBehind { | |
| 90 | #[serde(default)] | |
| 91 | behind: Option<u8>, | |
| 92 | #[serde(default)] | |
| 93 | mergeable_key: Option<String>, | |
| 94 | } | |
| 95 | ||
| 96 | impl 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. | |
| 106 | pub(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 | ||
| 117 | fn optional(value: &Option<String>) -> JsValue { | |
| 118 | value.as_deref().map_or(JsValue::NULL, JsValue::from) | |
| 119 | } | |
| 120 | ||
| 121 | fn 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 bar | 125 | /// 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. | |
| 127 | pub(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. | |
| 135 | fn 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 arrive | 140 | /// The lowercase name a `State` is stored and sent as. |
| 141 | fn 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. | |
| 149 | fn 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 timeouts | 160 | /// What a closed pull request was when it was closed. |
| 161 | #[derive(serde::Deserialize)] | |
| 162 | struct 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. | |
| 169 | fn 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. | |
| 179 | fn 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. | |
| 185 | fn 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 arrive | 194 | /// Unwraps an `Outcome`, returning its failure from the enclosing method. |
| 195 | macro_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 | ||
| 204 | struct 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 merge | 216 | /// 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 arrive | 219 | } |
| 220 | ||
| 221 | impl 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 arrive | 232 | .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 merge | 287 | let found: Outcome<Repo> = self |
| 288 | .timing | |
| Inbox: the events service tells people what needs them as events arrive | 289 | .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 merge | 297 | .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 arrive | 302 | } |
| 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 bar | 493 | 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 arrive | 494 | )); |
| 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 bar | 498 | // 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 arrive | 510 | // 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 bar | 522 | requested_by_id, requested_by_name, created_at, updated_at, milestone) |
| 523 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| Inbox: the events service tells people what needs them as events arrive | 524 | ) |
| 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 bar | 539 | optional_number(milestone.as_ref().map(|milestone| milestone.number)), |
| Inbox: the events service tells people what needs them as events arrive | 540 | ])? |
| 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 bar | 556 | // 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 arrive | 573 | 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 bar | 580 | .map(|label| label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()) |
| Inbox: the events service tells people what needs them as events arrive | 581 | .filter(|label| !label.is_empty()); |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 582 | let milestone = a.milestone; |
| Inbox: the events service tells people what needs them as events arrive | 583 | 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 bar | 594 | AND (? IS NULL OR milestone = ?) |
| Inbox: the events service tells people what needs them as events arrive | 595 | 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 bar | 603 | optional_number(milestone), |
| 604 | optional_number(milestone), | |
| Inbox: the events service tells people what needs them as events arrive | 605 | 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 bar | 671 | 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 arrive | 677 | 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 bar | 686 | // 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 arrive | 697 | 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 arrive | 699 | 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 arrive | 706 | 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 arrive | 713 | 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 arrive | 720 | 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 arrive | 733 | 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 published | 743 | let added: Vec<String> = assignees.iter().filter(|name| !before.contains(name)).cloned().collect(); |
| Inbox: the events service tells people what needs them as events arrive | 744 | 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 published | 750 | added: Some(added), |
| Inbox: the events service tells people what needs them as events arrive | 751 | ..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 arrive | 858 | 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 arrive | 958 | 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 arrive | 975 | "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 timeouts | 993 | edited_at: None, |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 994 | agent: None, |
| 995 | acting_for: None, | |
| 996 | advisory: false, | |
| Inbox: the events service tells people what needs them as events arrive | 997 | }; |
| 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 calendar | 1041 | ..CommentCreated::default() |
| Inbox: the events service tells people what needs them as events arrive | 1042 | }, |
| 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 arrive | 1063 | Ok(Outcome::Ok(comment)) |
| 1064 | } | |
| 1065 | ||
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 1066 | /// 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 calendar | 1086 | // 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 timeouts | 1088 | 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 calendar | 1090 | row.has_verdict(), |
| 1091 | &author_id, | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 1092 | &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 calendar | 1097 | 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 timeouts | 1098 | 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 calendar | 1113 | 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 timeouts | 1114 | 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 arrive | 1180 | // --- 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's | 1209 | // Unnamed, the change is its author's, unless an agent opened it. |
| Inbox: the events service tells people what needs them as events arrive | 1210 | let agent = match a.agent.trim() { |
| Pull requests: unnamed, a pull request is its author's, not an agent's | 1211 | "" 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 arrive | 1213 | 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 bar | 1227 | // 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 arrive | 1248 | // 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 bar | 1252 | if branch == base_name { |
| Inbox: the events service tells people what needs them as events arrive | 1253 | 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 bar | 1273 | // One open pull request for each branch and base. |
| Inbox: the events service tells people what needs them as events arrive | 1274 | 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 bar | 1278 | 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 arrive | 1280 | ) |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1281 | .bind(&[repo.id.as_str().into(), branch.into(), optional(&base)])? |
| Inbox: the events service tells people what needs them as events arrive | 1282 | .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 bar | 1287 | format!("Pull request #{} is already open from {branch} into {base_name}.", existing.n), |
| Inbox: the events service tells people what needs them as events arrive | 1288 | )); |
| 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 ready | 1308 | // 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 arrive | 1310 | 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 arrive | 1315 | 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 bar | 1320 | 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 arrive | 1323 | ) |
| 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 bar | 1346 | optional(&base), |
| Inbox: the events service tells people what needs them as events arrive | 1347 | ])? |
| 1348 | .run() | |
| 1349 | .await?; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1350 | let Some(mut pull) = self.pull(&repo.id, number).await? else { |
| Inbox: the events service tells people what needs them as events arrive | 1351 | return Ok(no_pull()); |
| 1352 | }; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1353 | fill_base(&mut pull, &repo); |
| Inbox: the events service tells people what needs them as events arrive | 1354 | 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 arrive | 1356 | 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 arrive | 1361 | 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 bar | 1367 | base: pull.base.clone(), |
| Inbox: the events service tells people what needs them as events arrive | 1368 | ..Self::pull_event(&pull) |
| 1369 | }, | |
| 1370 | ) | |
| 1371 | .await?; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1372 | // 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 arrive | 1377 | 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 bar | 1386 | 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 arrive | 1413 | }; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1414 | 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 arrive | 1426 | } |
| 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 merge | 1488 | 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 arrive | 1490 | 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 bar | 1501 | let default_branch = repo.default_branch.clone(); |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1502 | 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 arrive | 1503 | self.keep_prefetched(None); |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1504 | // 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 arrive | 1514 | } |
| 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 merge | 1523 | viewer: &Viewer, |
| Inbox: the events service tells people what needs them as events arrive | 1524 | ) -> 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 arrive | 1566 | }; |
| 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 arrive | 1568 | 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 arrive | 1571 | ) |
| 1572 | .await?; | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1573 | // 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 bar | 1587 | 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 arrive | 1594 | 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 merge | 1596 | 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 bar | 1597 | code_owners, |
| Inbox: the events service tells people what needs them as events arrive | 1598 | 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 timeouts | 1614 | /// 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 arrive | 1617 | &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 timeouts | 1627 | 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 arrive | 1639 | 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 bar | 1682 | target_branch: pull.base.clone(), |
| Inbox: the events service tells people what needs them as events arrive | 1683 | }, |
| 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 bar | 1702 | // 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 arrive | 1716 | 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 bar | 1720 | // 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 arrive | 1734 | 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 arrive | 1736 | // 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 bar | 1745 | SET assignees = COALESCE(?, assignees), updated_at = ? |
| Inbox: the events service tells people what needs them as events arrive | 1746 | 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 published | 1766 | // 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 bar | 1787 | 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 published | 1791 | } |
| Inbox: the events service tells people what needs them as events arrive | 1792 | 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 bar | 1798 | /// 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 bar | 1871 | } |
| 1872 | Ok(Outcome::Ok(())) | |
| 1873 | } | |
| 1874 | ||
| Inbox: the events service tells people what needs them as events arrive | 1875 | /// 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 bar | 1920 | // 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 arrive | 1926 | 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 timeouts | 1933 | // 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 arrive | 1936 | .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 timeouts | 1972 | /// 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 timeouts | 2028 | 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 arrive | 2084 | /// 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 bar | 2107 | let base = pull.base_branch(&repo.default_branch).to_owned(); |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 2108 | // 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 arrive | 2125 | } |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 2126 | 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 arrive | 2132 | } |
| 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 arrive | 2145 | ), |
| 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 merge | 2156 | self.record_merge_evaluations(&repo, &pull, &gate).await; |
| Inbox: the events service tells people what needs them as events arrive | 2157 | 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 bar | 2168 | "{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 arrive | 2169 | ), |
| 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 merge | 2176 | 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 arrive | 2180 | 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 arrive | 2213 | } |
| 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 bar | 2224 | target_branch: Some(base.clone()), |
| Inbox: the events service tells people what needs them as events arrive | 2225 | }, |
| 2226 | ) | |
| 2227 | .await?; | |
| 2228 | let landed = check!(landed); | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 2229 | self.record_merge_evaluations(&repo, &pull, &gate).await; |
| Inbox: the events service tells people what needs them as events arrive | 2230 | 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 bar | 2246 | // 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 arrive | 2249 | 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 arrive | 2371 | 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 bar | 2538 | // 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 arrive | 2559 | 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 merge | 2570 | // 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 arrive | 2572 | // 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 merge | 2575 | 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 arrive | 2576 | 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 merge | 2591 | pusher.clone(), |
| 2592 | now.as_str().into(), | |
| Inbox: the events service tells people what needs them as events arrive | 2593 | 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 merge | 2610 | pusher.clone(), |
| 2611 | now.as_str().into(), | |
| Inbox: the events service tells people what needs them as events arrive | 2612 | 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 arrive | 2623 | for moved in &pulls { |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 2624 | 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 arrive | 2631 | } |
| 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 arrive | 2639 | // 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 bar | 2646 | 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 arrive | 2654 | // 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 arrive | 2677 | ) |
| 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. |
| 2685 | trait 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 | ||
| 2692 | impl Carrying for PullEvent {} | |
| 2693 | ||
| Inbox: the events service tells people what needs them as events arrive | 2694 | fn 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 merge | 2703 | known_repos: std::cell::RefCell::new(std::collections::HashMap::new()), |
| Inbox: the events service tells people what needs them as events arrive | 2704 | }) |
| 2705 | } | |
| 2706 | ||
| 2707 | #[event(fetch)] | |
| 2708 | async 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 bar | 2726 | "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 arrive | 2734 | "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 timeouts | 2736 | "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 arrive | 2738 | "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 commit | 2742 | "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 arrive | 2756 | "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 bar | 2766 | "codeowners_errors" => reply(&work.codeowners_errors(args(body)?).await?), |
| Inbox: the events service tells people what needs them as events arrive | 2767 | "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 timeouts | 2776 | "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 arrive | 2778 | "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 project | 2781 | "contributions" => reply(&work.contributions(args(body)?).await?), |
| Inbox: the events service tells people what needs them as events arrive | 2782 | "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 arrive | 2802 | "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 arrive | 2809 | "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 merge | 2816 | // 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 arrive | 2827 | _ => 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)] | |
| 2834 | async 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 arrive | 2839 | 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 arrive | 2845 | 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 arrive | 2850 | 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 arrive | 2860 | // 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)] | |
| 2876 | mod 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 arrive | 2896 | 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 arrive | 2914 | // 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 timeouts | 2933 | |
| 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 arrive | 2953 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.