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