Skip to content
2,982 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The balance shows what was spent this month and in total1//! 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 calendar7mod agent_comments;
The balance shows what was spent this month and in total8mod authored;
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API9mod capture;
The balance shows what was spent this month and in total10mod checks;
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)11mod ghost;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar12mod codeowners;
Merge checks: statuses and check runs on every commit13mod commit_checks;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look14mod compute;
Chat controls, public profiles, shadcn selects, and no Docs tab in a project15mod contributions;
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step16mod confidence;
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API17mod guardrails;
Inbox: the events service tells people what needs them as events arrive18mod inbox;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar19mod labels;
The balance shows what was spent this month and in total20mod lifecycle;
21mod memory;
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API22mod mentions;
The balance shows what was spent this month and in total23mod mergeability;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar24mod milestones;
The balance shows what was spent this month and in total25mod plans;
26mod messages;
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily27mod prefetch;
The balance shows what was spent this month and in total28mod queue;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look29mod retired;
The balance shows what was spent this month and in total30mod reviews;
31mod rows;
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge32mod rulesets;
The balance shows what was spent this month and in total33mod runs;
34mod settings;
35mod statuses;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar36mod team_reviews;
The balance shows what was spent this month and in total37
38use g1t_contracts::events::{
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts39 ChangedFrom, CommentChanges, CommentCreated, CommentDeleted, CommentEdited, DeletedComment, Event, IssueEvent,
40 NewEvent, Publish, PullEvent, SessionAppended,
The balance shows what was spent this month and in total41};
42use g1t_contracts::identity::UsernameArgs;
Catching up with main takes seconds when the two sides touched different files43use g1t_contracts::repos::{
Fast pages, required checks on the branch, self-hosted runners, honest incidents44 ForkArgs, GetArgs, HeadArgs, LandArgs, Landed, NeedsAgentReason, PullBranchUpdate, ReadableArgs, Repo, RepoPath,
Catching up with main takes seconds when the two sides touched different files45 UpdatePullBranchArgs,
46};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look47use g1t_contracts::access::{self, Capability, Denied};
The balance shows what was spent this month and in total48use g1t_contracts::time::rfc3339;
49use g1t_contracts::work::*;
50use futures_util::future::{try_join, try_join3, try_join_all};
51use g1t_contracts::{FailureCode, Outcome, User, Viewer, new_id};
52use g1t_kit::{args, now_ms, reply, rpc_method};
53use serde::Serialize;
54use worker::wasm_bindgen::JsValue;
55use 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 look59use retired::writable;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar60use rows::{CommentRow, IssueRow, MovedRow, NumberRow, PULL_COLUMNS, PullRow, SessionRow, Snapshot};
The balance shows what was spent this month and in total61
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge62pub(crate) const SOURCE: &str = "work";
The balance shows what was spent this month and in total63const MAX_ENTRY_BATCH: usize = 200;
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar64pub(crate) const MAX_ENTRY_CHARS: usize = 64_000;
The balance shows what was spent this month and in total65const MAX_TITLE_CHARS: usize = 200;
66const SESSION_PAGE: u32 = 500;
67const LIST_PAGE: u32 = 100;
68const MAX_ASSIGNEES: usize = 10;
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge69pub(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 total70
71const 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 bar79 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 total82
83fn no_issue<T>() -> Outcome<T> {
84 Outcome::fail(FailureCode::NotFound, "Issue not found.")
85}
86
87fn 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 daily91/// 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)]
94struct StoredBehind {
95 #[serde(default)]
96 behind: Option<u8>,
97 #[serde(default)]
98 mergeable_key: Option<String>,
99}
100
101impl 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 look109/// Refuses `actor` unless their role on `repo` has `capability`: not found
110/// when they cannot read it, forbidden with the role it needs otherwise.
111pub(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 total122fn optional(value: &Option<String>) -> JsValue {
123 value.as_deref().map_or(JsValue::NULL, JsValue::from)
124}
125
126fn 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 bar130/// 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.
132pub(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.
140fn 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 total145/// The lowercase name a `State` is stored and sent as.
146fn 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.
154fn 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 timeouts165/// What a closed pull request was when it was closed.
166#[derive(serde::Deserialize)]
167struct 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.
174fn 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.
184fn 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.
190fn 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 total199/// Unwraps an `Outcome`, returning its failure from the enclosing method.
200macro_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
209struct 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 daily216 /// 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 merge221 /// 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 total224}
225
226impl 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 total237 .await
238 }
239
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights240 /// 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 total246 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 rights252 let mut owner = pull.owner().clone();
The balance shows what was spent this month and in total253 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 rights254 && !owner.is_member(&path.namespace.to_lowercase())
The balance shows what was spent this month and in total255 {
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights256 owner.workspaces.push(g1t_contracts::Membership {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look257 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 total260 }
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights261 Ok(Some(owner))
The balance shows what was spent this month and in total262 }
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 merge292 let found: Outcome<Repo> = self
293 .timing
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily294 .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 merge302 .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 total307 }
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 step341 .prepare(format!("SELECT {PULL_COLUMNS} FROM pulls WHERE repo_id = ? AND number = ?"))
The balance shows what was spent this month and in total342 .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-agent412 /// "assigned ana" or "requested a review from g1t".
The balance shows what was spent this month and in total413 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 rights453 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 total455 ..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 rights478 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 total480 issue: pull.issue,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step481 confidence: pull.confidence.clone(),
The balance shows what was spent this month and in total482 ..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-agent488 /// 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 step489 /// 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-guardrails495 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 step498 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 bar505 milestone: None,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step506 })
507 .await
508 }
509
The balance shows what was spent this month and in total510 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 bar521 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 total522 ));
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 look525 check!(writable(&repo));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar526 // 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 incidents536 // 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 total540
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 rights545 // 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 total547 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 bar551 requested_by_id, requested_by_name, created_at, updated_at, milestone)
552 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
The balance shows what was spent this month and in total553 )
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 incidents559 body.into(),
The balance shows what was spent this month and in total560 serde_json::to_string(&labels)?.into(),
Fast pages, required checks on the branch, self-hosted runners, honest incidents561 "[]".into(),
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights562 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 total566 timestamp.as_str().into(),
567 timestamp.as_str().into(),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar568 optional_number(milestone.as_ref().map(|milestone| milestone.number)),
The balance shows what was spent this month and in total569 ])?
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 API575 self.apply_label_rule(&a.actor, &issue, &[]).await?;
The balance shows what was spent this month and in total576 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 bar586 // 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 total603 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 daily607 let state = state_name(a.state);
The balance shows what was spent this month and in total608 let label = a
609 .label
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar610 .map(|label| label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase())
The balance shows what was spent this month and in total611 .filter(|label| !label.is_empty());
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar612 let milestone = a.milestone;
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily613 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 bar624 AND (? IS NULL OR milestone = ?)
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily625 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 bar633 optional_number(milestone),
634 optional_number(milestone),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily635 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 total641 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 daily644 /// 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 total646 async fn get_issue(&self, a: ViewArgs) -> Result<Outcome<IssueDetail>> {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily647 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 total677 }
678
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look679 /// The issue, if `actor` wrote it or may triage the repository's issues.
The balance shows what was spent this month and in total680 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 look687 check!(writable(&repo));
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights688 if issue.owner().id != actor.id {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look689 check!(allowed(Some(actor), &repo, Capability::Triage));
The balance shows what was spent this month and in total690 }
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 bar701 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 total707 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 bar711 // 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 total722 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 bar728 assignees = COALESCE(?, assignees), updated_at = ?
The balance shows what was spent this month and in total729 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 API741 let labels_before = issue.labels.clone();
The balance shows what was spent this month and in total742 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 API745 self.apply_label_rule(&a.actor, &issue, &labels_before).await?;
The balance shows what was spent this month and in total746 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 published763 let added: Vec<String> = assignees.iter().filter(|name| !before.contains(name)).cloned().collect();
The balance shows what was spent this month and in total764 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 published770 added: Some(added),
The balance shows what was spent this month and in total771 ..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 daily926 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 total939 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 look972 check!(writable(&repo));
The balance shows what was spent this month and in total973 // 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 bar975 // 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 total978 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 rights987 if a.verdict.is_some() && pull.is_owned_by(&a.actor.id) {
The balance shows what was spent this month and in total988 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 bar994 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 total996 "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 timeouts1014 edited_at: None,
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar1015 agent: None,
1016 acting_for: None,
1017 advisory: false,
The balance shows what was spent this month and in total1018 };
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 bar1052 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 total1055 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 calendar1065 ..CommentCreated::default()
The balance shows what was spent this month and in total1066 },
1067 )
1068 .await?;
1069 Ok(Outcome::Ok(comment))
1070 }
1071
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1072 /// 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 calendar1092 // 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 timeouts1094 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 calendar1096 row.has_verdict(),
1097 &author_id,
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1098 &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 calendar1103 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 timeouts1104 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 calendar1119 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 timeouts1120 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 total1186 // --- 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 look1193 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 total1199 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's1220 // Unnamed, the change is its author's, unless an agent opened it.
The balance shows what was spent this month and in total1221 let agent = match a.agent.trim() {
Pull requests: unnamed, a pull request is its author's, not an agent's1222 "" 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 total1224 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 bar1238 // 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 total1259 // 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 bar1263 if branch == base_name {
The balance shows what was spent this month and in total1264 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 bar1284 // One open pull request for each branch and base.
The balance shows what was spent this month and in total1285 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 bar1289 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 total1291 )
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1292 .bind(&[repo.id.as_str().into(), branch.into(), optional(&base)])?
The balance shows what was spent this month and in total1293 .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 bar1298 format!("Pull request #{} is already open from {branch} into {base_name}.", existing.n),
The balance shows what was spent this month and in total1299 ));
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 ready1319 // 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 total1321 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 rights1325 // 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 total1329 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 bar1334 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 total1337 )
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 rights1354 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 total1358 timestamp.as_str().into(),
1359 timestamp.as_str().into(),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1360 optional(&base),
The balance shows what was spent this month and in total1361 ])?
1362 .run()
1363 .await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1364 let Some(mut pull) = self.pull(&repo.id, number).await? else {
The balance shows what was spent this month and in total1365 return Ok(no_pull());
1366 };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1367 fill_base(&mut pull, &repo);
The balance shows what was spent this month and in total1368 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-agent1379 format!("assigned this to g1t, which opened #{}", pull.number)
The balance shows what was spent this month and in total1380 } 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 bar1392 base: pull.base.clone(),
The balance shows what was spent this month and in total1393 ..Self::pull_event(&pull)
1394 },
1395 )
1396 .await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1397 // 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 total1400 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 bar1409 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 daily1436 };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1437 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 total1449 }
1450
Fast pages, required checks on the branch, self-hosted runners, honest incidents1451 /// `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 daily1459 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 incidents1466 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 daily1489 /// 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 total1507 async fn get_pull(&self, a: ViewArgs) -> Result<Outcome<PullDetail>> {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1508 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 merge1511 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 daily1513 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 total1518 };
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1519 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 bar1524 let default_branch = repo.default_branch.clone();
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1525 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 daily1526 self.keep_prefetched(None);
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1527 // 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 daily1537 }
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 merge1546 viewer: &Viewer,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1547 ) -> 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 total1552 // 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 daily1562 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 step1587 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 total1591 };
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1592 let (((behind, (lifecycle, confidence), (mergeable, conflicts)), (landing, stalled), comments), (checks, overlaps, review_pending)) =
The balance shows what was spent this month and in total1593 try_join(
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1594 try_join3(standing, self.landing_state(&pull.id), async { Ok(comments) }),
The balance shows what was spent this month and in total1595 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 step1602 // 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 merge1606 // 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 bar1620 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 total1627 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 merge1629 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 bar1630 code_owners,
The balance shows what was spent this month and in total1631 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 incidents1640 statuses,
The balance shows what was spent this month and in total1641 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 timeouts1649 /// 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 total1652 &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 look1658 check!(writable(&repo));
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1659 if !pull.is_owned_by(&actor.id) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1660 check!(allowed(Some(actor), &repo, Capability::Triage));
The balance shows what was spent this month and in total1661 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1662 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 total1674 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 files1683 /// 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 rights1685 /// 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 files1688 /// 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 look1692 check!(writable(&repo));
Catching up with main takes seconds when the two sides touched different files1693 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 look1699 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 rights1700 if !pull.is_owned_by(&a.actor.id) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1701 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 rights1703 "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 look1704 ));
1705 }
Catching up with main takes seconds when the two sides touched different files1706 } else {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1707 check!(allowed(Some(&a.actor), &repo, Capability::Push));
Catching up with main takes seconds when the two sides touched different files1708 }
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 bar1717 target_branch: pull.base.clone(),
Catching up with main takes seconds when the two sides touched different files1718 },
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 total1735 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 bar1737 // 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 total1751 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 bar1755 // 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 total1769 Some(names) => {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1770 // g1t is not an account; everyone else has to be.
The balance shows what was spent this month and in total1771 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 rights1779 // 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 total1781 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 bar1791 SET assignees = COALESCE(?, assignees), updated_at = ?
The balance shows what was spent this month and in total1792 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 published1812 // 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 bar1833 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 published1837 }
The balance shows what was spent this month and in total1838 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 bar1844 /// 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 total1920 /// 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 bar1965 // 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 total1971 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 timeouts1978 // 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 total1981 .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 timeouts2017 /// 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 total2127 /// 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 look2134 check!(writable(&repo));
The balance shows what was spent this month and in total2135 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 bar2150 let base = pull.base_branch(&repo.default_branch).to_owned();
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2151 // 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 total2168 }
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2169 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 total2175 }
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 bar2187 "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 total2188 ),
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 look2198 check!(allowed(Some(&a.actor), &repo, Capability::Merge));
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2199 self.record_merge_evaluations(&repo, &pull, &gate).await;
The balance shows what was spent this month and in total2200 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 bar2211 "{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 total2212 ),
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 look2218 check!(allowed(Some(&a.actor), &repo, Capability::Merge));
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2219 self.record_merge_evaluations(&repo, &pull, &gate).await;
The balance shows what was spent this month and in total2220 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 bar2234 target_branch: Some(base.clone()),
The balance shows what was spent this month and in total2235 },
2236 )
2237 .await?;
2238 let landed = check!(landed);
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2239 self.record_merge_evaluations(&repo, &pull, &gate).await;
The balance shows what was spent this month and in total2240 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 bar2256 // 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 total2259 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 rights2364 // 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 daily2366 let author = [JsValue::from(viewer.id.as_str())];
The balance shows what was spent this month and in total2367 let found = self
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily2368 .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 rights2375 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 daily2376 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 rights2383 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 daily2384 ORDER BY updated_at DESC LIMIT 50)"
2385 ))
2386 .bind(&author)?,
2387 ]),
2388 )
The balance shows what was spent this month and in total2389 .await?;
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily2390 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 total2393 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 daily2395 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 total2399 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 daily2400 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 total2406 // 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 rights2444 if !pull.is_owned_by(&a.actor.id) {
The balance shows what was spent this month and in total2445 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 rights2447 "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 total2448 ));
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 bar2548 // 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 total2569 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 merge2580 // 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 total2582 // 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 merge2585 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 total2586 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 rights2588 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 total2590 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 merge2601 pusher.clone(),
2602 now.as_str().into(),
The balance shows what was spent this month and in total2603 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 merge2620 pusher.clone(),
2621 now.as_str().into(),
The balance shows what was spent this month and in total2622 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 bar2633 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 total2637 }
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 bar2646 let into = match event.data["defaultBranch"].as_bool() {
2647 Some(true) => mergeability::Moved::DefaultBranch,
2648 _ => match git_ref.strip_prefix("refs/heads/") {
2649 Some(branch) => mergeability::Moved::Branch(branch),
2650 None => mergeability::Moved::Nothing,
2651 },
2652 };
2653 self.after_push(repo_id, into, &moved_ids).await;
The balance shows what was spent this month and in total2654 // 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 rights2664 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 total2669 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 total2677 )
2678 .await?;
2679 }
2680 Ok(())
2681 }
2682}
2683
Merge branch 'worktree-agent-a3abfcce648e87dca'2684/// An event's data that carries on what caused the event it follows from.
2685trait Carrying: serde::Serialize + Sized {
2686 /// As JSON, marked as a workflow job's doing when `cause` was.
2687 fn carrying(self, cause: &serde_json::Value) -> serde_json::Value {
2688 g1t_contracts::events::carried(self, cause)
2689 }
2690}
2691
2692impl Carrying for PullEvent {}
2693
The balance shows what was spent this month and in total2694fn 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 daily2701 timing: g1t_kit::d1::Timing::default(),
2702 prefetched: std::cell::RefCell::new(None),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2703 known_repos: std::cell::RefCell::new(std::collections::HashMap::new()),
The balance shows what was spent this month and in total2704 })
2705}
2706
2707#[event(fetch)]
2708async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
2709 let Some(method) = rpc_method(&request) else {
2710 return Response::error("Not found", 404);
2711 };
Fast pages, required checks on the branch, self-hosted runners, honest incidents2712 // 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 total2714 let body: serde_json::Value = request.json().await?;
Fast pages, required checks on the branch, self-hosted runners, honest incidents2715 let mut work = service(&env)?;
2716 work.db = db;
The balance shows what was spent this month and in total2717
Fast pages, required checks on the branch, self-hosted runners, honest incidents2718 let answered = match method.as_str() {
The balance shows what was spent this month and in total2719 "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 step2720 "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 total2722 "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 bar2728 "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 total2736 "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 timeouts2738 "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 calendar2740 // 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 total2744 "start_checks" => reply(&work.start_checks(args(body)?).await?),
Fast pages, required checks on the branch, self-hosted runners, honest incidents2745 "seen_checks" => reply(&work.seen_checks(args(body)?).await?),
The balance shows what was spent this month and in total2746 "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 commit2748 "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 total2762 "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 arrive2772 "inbox_subject" => reply(&work.inbox_subject(args(body)?).await?),
The balance shows what was spent this month and in total2773 "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 bar2778 "codeowners_errors" => reply(&work.codeowners_errors(args(body)?).await?),
The balance shows what was spent this month and in total2779 "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 incidents2783 "pulls_for_repos" => reply(&work.pulls_for_repos(args(body)?).await?),
The balance shows what was spent this month and in total2784 "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 files2786 "catch_up_pull" => reply(&work.catch_up_pull(args(body)?).await?),
The balance shows what was spent this month and in total2787 "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 timeouts2789 "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 total2791 "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 project2794 "contributions" => reply(&work.contributions(args(body)?).await?),
The balance shows what was spent this month and in total2795 "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 API2819 // 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 look2823 // 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 total2834 "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 API2836 // 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-agent2838 // @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 API2839 "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 merge2844 // 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 total2855 _ => Response::error("Unknown method", 404),
Fast pages, required checks on the branch, self-hosted runners, honest incidents2856 };
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily2857 served.finish_timed(answered, &work.timing)
The balance shows what was spent this month and in total2858}
2859
2860/// Events from the bus, delivered on this service's own queue.
2861#[event(queue)]
2862async 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 merge2866 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 total2867 message.ack();
2868 continue;
2869 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2870 // 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 look2888 // 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 API2893 capture::on_event(&work, message.body()).await;
The balance shows what was spent this month and in total2894 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 rights2899
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)]
2905mod 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 timeouts2962
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 rights2982}

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