Skip to content
2,987 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.

Put the stylesheet at the top of the head, before React Router's preloads1//! 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;
Put the stylesheet at the top of the head, before React Router's preloads8mod authored;
9mod capture;
10mod 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;
Put the stylesheet at the top of the head, before React Router's preloads14mod compute;
Chat controls, public profiles, shadcn selects, and no Docs tab in a project15mod contributions;
Put the stylesheet at the top of the head, before React Router's preloads16mod confidence;
17mod 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;
Put the stylesheet at the top of the head, before React Router's preloads20mod lifecycle;
21mod memory;
22mod mentions;
23mod mergeability;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar24mod milestones;
Put the stylesheet at the top of the head, before React Router's preloads25mod plans;
26mod messages;
27mod prefetch;
28mod queue;
29mod retired;
30mod reviews;
31mod rows;
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge32mod rulesets;
Put the stylesheet at the top of the head, before React Router's preloads33mod runs;
34mod settings;
35mod statuses;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar36mod team_reviews;
Put the stylesheet at the top of the head, before React Router's preloads37
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,
Put the stylesheet at the top of the head, before React Router's preloads41};
42use g1t_contracts::identity::UsernameArgs;
43use g1t_contracts::repos::{
44 ForkArgs, GetArgs, HeadArgs, LandArgs, Landed, NeedsAgentReason, PullBranchUpdate, ReadableArgs, Repo, RepoPath,
45 UpdatePullBranchArgs,
46};
47use g1t_contracts::access::{self, Capability, Denied};
48use 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
59use 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};
Put the stylesheet at the top of the head, before React Router's preloads61
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge62pub(crate) const SOURCE: &str = "work";
Put the stylesheet at the top of the head, before React Router's preloads63const 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;
Put the stylesheet at the top of the head, before React Router's preloads65const 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.";
Put the stylesheet at the top of the head, before React Router's preloads70
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";
Put the stylesheet at the top of the head, before React Router's preloads82
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
91/// Whether a pull request was behind when its mergeability was last
92/// worked out, and for which pair of commits (mergeability.rs).
93#[derive(serde::Deserialize)]
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
109/// Refuses `actor` unless their role on `repo` has `capability`: not found
110/// when they cannot read it, forbidden with the role it needs otherwise.
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
122fn 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
Put the stylesheet at the top of the head, before React Router's preloads145/// 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
Put the stylesheet at the top of the head, before React Router's preloads199/// 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,
216 /// Where this request's time went, for its `Server-Timing`.
217 timing: g1t_kit::d1::Timing,
218 /// A pull request's rows read in one batch for this request
219 /// (prefetch.rs), which the helpers below read instead of the database.
220 prefetched: std::cell::RefCell<Option<std::rc::Rc<prefetch::Prefetched>>>,
Merge rulesets: branch and tag rules, agent-first, enforced on push and 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>>,
Put the stylesheet at the top of the head, before React Router's preloads224}
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)))
Put the stylesheet at the top of the head, before React Router's preloads237 .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> {
Put the stylesheet at the top of the head, before React Router's preloads246 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();
Put the stylesheet at the top of the head, before React Router's preloads253 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())
Put the stylesheet at the top of the head, before React Router's preloads255 {
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 {
Put the stylesheet at the top of the head, before React Router's preloads257 base_permission: Some(access::BasePermission::Read),
258 ..g1t_contracts::Membership::member(path.namespace.to_lowercase())
259 });
260 }
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))
Put the stylesheet at the top of the head, before React Router's preloads262 }
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
Put the stylesheet at the top of the head, before React Router's preloads294 .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)
Put the stylesheet at the top of the head, before React Router's preloads307 }
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
341 .prepare(format!("SELECT {PULL_COLUMNS} FROM pulls WHERE repo_id = ? AND number = ?"))
342 .bind(&[repo_id.into(), number.into()])?
343 .first::<PullRow>(None)
344 .await?
345 .map(Pull::from))
346 }
347
348 /// The repository and one of its issues, as seen by `viewer`.
349 async fn issue_at(
350 &self,
351 path: &RepoPath,
352 number: u32,
353 viewer: &Viewer,
354 ) -> Result<Outcome<(Repo, Issue)>> {
355 let Outcome::Ok(repo) = self.repo(path, viewer).await? else {
356 return Ok(no_issue());
357 };
358 Ok(match self.issue(&repo.id, number).await? {
359 Some(issue) => Outcome::Ok((repo, issue)),
360 None => no_issue(),
361 })
362 }
363
364 /// The repository and one of its pull requests, as seen by `viewer`.
365 async fn pull_at(
366 &self,
367 path: &RepoPath,
368 number: u32,
369 viewer: &Viewer,
370 ) -> Result<Outcome<(Repo, Pull)>> {
371 let Outcome::Ok(repo) = self.repo(path, viewer).await? else {
372 return Ok(no_pull());
373 };
374 Ok(match self.pull(&repo.id, number).await? {
375 Some(pull) => Outcome::Ok((repo, pull)),
376 None => no_pull(),
377 })
378 }
379
380 /// Records something that happened to an issue or a pull request, so
381 /// that it shows in the conversation where it happened. `text` is what
382 /// `author` did, as the rest of a sentence starting with their name.
383 pub(crate) async fn note(
384 &self,
385 repo_id: &str,
386 number: u32,
387 author: (&str, &str),
388 text: &str,
389 ) -> Result<()> {
390 let now = now_ms();
391 self.db
392 .prepare(
393 "INSERT INTO comments
394 (id, repo_id, number, author_id, author_name, body, kind, created_at)
395 VALUES (?, ?, ?, ?, ?, ?, 'event', ?)",
396 )
397 .bind(&[
398 new_id("cmt", now).into(),
399 repo_id.into(),
400 number.into(),
401 author.0.into(),
402 author.1.into(),
403 text.into(),
404 rfc3339(now).into(),
405 ])?
406 .run()
407 .await?;
408 Ok(())
409 }
410
411 /// Notes who was added to and removed from a list of people, such as
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent412 /// "assigned ana" or "requested a review from g1t".
Put the stylesheet at the top of the head, before React Router's preloads413 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),
Put the stylesheet at the top of the head, before React Router's preloads455 ..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),
Put the stylesheet at the top of the head, before React Router's preloads480 issue: pull.issue,
481 confidence: pull.confidence.clone(),
482 ..PullEvent::default()
483 }
484 }
485
486 // --- Issues ------------------------------------------------------------
487
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent488 /// Opens an issue for g1t to take at once: refused before
Put the stylesheet at the top of the head, before React Router's preloads489 /// 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 }
Put the stylesheet at the top of the head, before React Router's preloads498 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,
Put the stylesheet at the top of the head, before React Router's preloads506 })
507 .await
508 }
509
510 async fn open_issue(&self, a: OpenIssueArgs) -> Result<Outcome<Issue>> {
511 if !a.actor.verified {
512 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
513 }
514 let title = match valid_title(&a.title) {
515 Ok(title) => title,
516 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
517 };
518 let Some(labels) = normalize_labels(&a.labels) else {
519 return Ok(Outcome::fail(
520 FailureCode::Invalid,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar521 format!("An issue can have up to {MAX_LABELS} labels of up to {MAX_LABEL_CHARS} characters each."),
Put the stylesheet at the top of the head, before React Router's preloads522 ));
523 };
524 let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?);
525 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 };
Put the stylesheet at the top of the head, before React Router's preloads536 // 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));
540
541 let now = now_ms();
542 let id = new_id("iss", now);
543 let number = self.next_number(&repo.id).await?;
544 let timestamp = rfc3339(now);
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's 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);
Put the stylesheet at the top of the head, before React Router's preloads547 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
Put the stylesheet at the top of the head, before React Router's preloads553 )
554 .bind(&[
555 id.as_str().into(),
556 repo.id.as_str().into(),
557 number.into(),
558 title.into(),
559 body.into(),
560 serde_json::to_string(&labels)?.into(),
561 "[]".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())),
Put the stylesheet at the top of the head, before React Router's preloads566 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)),
Put the stylesheet at the top of the head, before React Router's preloads569 ])?
570 .run()
571 .await?;
572 let Some(issue) = self.issue(&repo.id, number).await? else {
573 return Ok(no_issue());
574 };
575 self.apply_label_rule(&a.actor, &issue, &[]).await?;
576 self.publish(
577 "issue.opened",
578 &repo.id,
579 &a.actor,
580 IssueEvent {
581 title: Some(issue.title.clone()),
582 ..Self::issue_event(&issue)
583 },
584 )
585 .await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top 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 }
Put the stylesheet at the top of the head, before React Router's preloads603 Ok(Outcome::Ok(issue))
604 }
605
606 async fn list_issues(&self, a: ListIssuesArgs) -> Result<Outcome<Vec<Issue>>> {
607 let state = state_name(a.state);
608 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())
Put the stylesheet at the top of the head, before React Router's preloads611 .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;
Put the stylesheet at the top of the head, before React Router's preloads613 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 = ?)
Put the stylesheet at the top of the head, before React Router's preloads625 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),
Put the stylesheet at the top of the head, before React Router's preloads635 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?);
641 Ok(Outcome::Ok(rows.into_iter().map(Issue::from).collect()))
642 }
643
644 /// An issue, the pull requests for it and its comments: one batch,
645 /// started beside the access check (prefetch.rs).
646 async fn get_issue(&self, a: ViewArgs) -> Result<Outcome<IssueDetail>> {
647 let number = a.number;
648 let read = |repo_id: String| async move {
649 let key = [JsValue::from(repo_id.as_str()), JsValue::from(number)];
650 let statements = vec![
651 self.db
652 .prepare(format!("SELECT {ISSUE_COLUMNS} FROM issues WHERE repo_id = ?1 AND number = ?2"))
653 .bind(&key)?,
654 self.db
655 .prepare(format!(
656 "SELECT {PULL_COLUMNS} FROM pulls
657 WHERE issue_id = (SELECT id FROM issues WHERE repo_id = ?1 AND number = ?2)
658 ORDER BY number"
659 ))
660 .bind(&key)?,
661 self.db
662 .prepare("SELECT * FROM comments WHERE repo_id = ?1 AND number = ?2 ORDER BY id LIMIT 500")
663 .bind(&key)?,
664 ];
665 let results = self.timing.db(3, self.db.batch(statements)).await?;
666 let rows = |index: usize| results.get(index).ok_or_else(|| worker::Error::RustError("short batch".into()));
667 Ok::<_, worker::Error>((
668 rows(0)?.results::<IssueRow>()?.into_iter().next().map(Issue::from),
669 rows(1)?.results::<PullRow>()?.into_iter().map(Pull::from).collect::<Vec<_>>(),
670 rows(2)?.results::<CommentRow>()?.into_iter().map(Comment::from).collect::<Vec<_>>(),
671 ))
672 };
673 let Outcome::Ok((_, (Some(issue), pulls, comments))) = self.repo_then(&a.repo, &a.viewer, read).await? else {
674 return Ok(no_issue());
675 };
676 Ok(Outcome::Ok(IssueDetail { comments, pulls, issue }))
677 }
678
679 /// The issue, if `actor` wrote it or may triage the repository's issues.
680 async fn manageable_issue(
681 &self,
682 actor: &User,
683 path: &RepoPath,
684 number: u32,
685 ) -> Result<Outcome<Issue>> {
686 let (repo, issue) = check!(self.issue_at(path, number, &Some(actor.clone())).await?);
687 check!(writable(&repo));
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights688 if issue.owner().id != actor.id {
Put the stylesheet at the top of the head, before React Router's preloads689 check!(allowed(Some(actor), &repo, Capability::Triage));
690 }
691 Ok(Outcome::Ok(issue))
692 }
693
694 async fn update_issue(&self, a: UpdateIssueArgs) -> Result<Outcome<Issue>> {
695 let issue = check!(self.manageable_issue(&a.actor, &a.repo, a.number).await?);
696 let title = match a.title.as_deref().map(valid_title) {
697 Some(Err(message)) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
698 Some(Ok(title)) => Some(title.to_owned()),
699 None => None,
700 };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top 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 }
Put the stylesheet at the top of the head, before React Router's preloads707 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 }
Put the stylesheet at the top of the head, before React Router's preloads722 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 = ?
Put the stylesheet at the top of the head, before React Router's preloads729 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();
741 let labels_before = issue.labels.clone();
742 let Some(issue) = self.issue(&issue.repo_id, issue.number).await? else {
743 return Ok(no_issue());
744 };
745 self.apply_label_rule(&a.actor, &issue, &labels_before).await?;
746 self.publish(
747 "issue.updated",
748 &issue.repo_id,
749 &a.actor,
750 Self::issue_event(&issue),
751 )
752 .await?;
753 if let Some(assignees) = assignees {
754 self.note_changes(
755 &issue.repo_id,
756 issue.number,
757 &a.actor,
758 &before,
759 &assignees,
760 ("assigned", "unassigned"),
761 )
762 .await?;
Events: review requests, assignments, stops and deployments are published763 let added: Vec<String> = assignees.iter().filter(|name| !before.contains(name)).cloned().collect();
Put the stylesheet at the top of the head, before React Router's preloads764 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),
Put the stylesheet at the top of the head, before React Router's preloads771 ..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>> {
926 let read = |repo_id: String| async move {
927 let query = self
928 .db
929 .prepare(
930 "SELECT
931 (SELECT count(*) FROM issues WHERE repo_id = ?1 AND state = 'open') AS issues,
932 (SELECT count(*) FROM pulls
933 WHERE repo_id = ?1 AND status IN ('draft', 'open')) AS pulls",
934 )
935 .bind(&[repo_id.into()])?;
936 self.timing.db(1, query.first::<Counts>(None)).await
937 };
938 let (_, counts) = check!(self.repo_then(&a.repo, &a.viewer, read).await?);
939 Ok(Outcome::Ok(counts.unwrap_or(Counts {
940 issues: 0,
941 pulls: 0,
942 })))
943 }
944
945 // --- Comments ----------------------------------------------------------
946
947 async fn add_comment(&self, a: AddCommentArgs) -> Result<Outcome<Comment>> {
948 if !a.actor.verified {
949 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
950 }
951 let body = a.body.trim();
952 // An approval speaks for itself; anything else has to say something.
953 if body.is_empty() && a.verdict != Some(Verdict::Approve) {
954 return Ok(Outcome::fail(
955 FailureCode::Invalid,
956 "A comment cannot be empty.",
957 ));
958 }
959 let path = a
960 .path
961 .as_deref()
962 .map(str::trim)
963 .filter(|path| !path.is_empty());
964 let line = a.line.filter(|line| *line > 0 && path.is_some());
965 if body.chars().count() > MAX_ENTRY_CHARS {
966 return Ok(Outcome::fail(
967 FailureCode::Invalid,
968 "That comment is too long.",
969 ));
970 }
971 let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?);
972 check!(writable(&repo));
973 // The number names an issue or a pull request, never both.
974 let mut pull_id = None;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top 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;
Put the stylesheet at the top of the head, before React Router's preloads978 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) {
Put the stylesheet at the top of the head, before React Router's preloads988 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();
Put the stylesheet at the top of the head, before React Router's preloads996 "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,
Put the stylesheet at the top of the head, before React Router's preloads1018 };
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 }
Put the stylesheet at the top of the head, before React Router's preloads1055 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()
Put the stylesheet at the top of the head, before React Router's preloads1066 },
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
Put the stylesheet at the top of the head, before React Router's preloads1186 // --- 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?);
1193 check!(writable(&repo));
1194 // g1t's own agent at work spends the workspace's compute; a pull
1195 // request anyone else's agent makes is like any other.
1196 if matches!(a.runtime, Runtime::Hosted) {
1197 check!(allowed(Some(&a.actor), &repo, Capability::Run));
1198 }
1199 let issue = match a.issue {
1200 Some(number) => match self.issue(&repo.id, number).await? {
1201 Some(issue) if issue.state == State::Open => Some(issue),
1202 Some(_) => {
1203 return Ok(Outcome::fail(
1204 FailureCode::Conflict,
1205 "This issue is closed.",
1206 ));
1207 }
1208 None => return Ok(no_issue()),
1209 },
1210 None => None,
1211 };
1212 // A pull request for an issue takes the issue's title unless given one.
1213 let title = match (a.title.trim(), &issue) {
1214 ("", Some(issue)) => issue.title.clone(),
1215 (title, _) => match valid_title(title) {
1216 Ok(title) => title.to_owned(),
1217 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
1218 },
1219 };
Pull requests: unnamed, a pull request is its author's, not an agent's1220 // Unnamed, the change is its author's, unless an agent opened it.
Put the stylesheet at the top of the head, before React Router's preloads1221 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(),
Put the stylesheet at the top of the head, before React Router's preloads1224 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());
Put the stylesheet at the top of the head, before React Router's preloads1259 // 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 {
Put the stylesheet at the top of the head, before React Router's preloads1264 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.
Put the stylesheet at the top of the head, before React Router's preloads1285 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 ?",
Put the stylesheet at the top of the head, before React Router's preloads1291 )
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)])?
Put the stylesheet at the top of the head, before React Router's preloads1293 .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),
Put the stylesheet at the top of the head, before React Router's preloads1299 ));
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" };
Put the stylesheet at the top of the head, before React Router's preloads1321 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);
Put the stylesheet at the top of the head, before React Router's preloads1329 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
Put the stylesheet at the top of the head, before React Router's preloads1337 )
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())),
Put the stylesheet at the top of the head, before React Router's preloads1358 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),
Put the stylesheet at the top of the head, before React Router's preloads1361 ])?
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 {
Put the stylesheet at the top of the head, before React Router's preloads1365 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);
Put the stylesheet at the top of the head, before React Router's preloads1368 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)
Put the stylesheet at the top of the head, before React Router's preloads1380 } 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 }
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1386 // Its head and merge refs in the repository, before its events go out.
1387 self.keep_pull_refs(&pull).await;
Put the stylesheet at the top of the head, before React Router's preloads1388 self.publish(
1389 "pull.opened",
1390 &repo.id,
1391 &a.actor,
1392 PullEvent {
1393 agent: Some(pull.agent.clone()),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1394 base: pull.base.clone(),
Put the stylesheet at the top of the head, before React Router's preloads1395 ..Self::pull_event(&pull)
1396 },
1397 )
1398 .await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1399 // Its code owners asked to review (codeowners.rs).
1400 self.refresh_code_owners(&pull).await;
1401 let pull = self.pull(&repo.id, number).await?.unwrap_or(pull);
Put the stylesheet at the top of the head, before React Router's preloads1402 Ok(Outcome::Ok(pull))
1403 }
1404
1405 async fn list_pulls(&self, a: ListPullsArgs) -> Result<Outcome<Vec<Pull>>> {
1406 let filter = match a.state {
1407 Some(State::Open) => "AND status IN ('draft', 'open')",
1408 Some(State::Closed) => "AND status IN ('merged', 'closed')",
1409 None => "",
1410 };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1411 let label = a
1412 .label
1413 .map(|label| label.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase())
1414 .filter(|label| !label.is_empty());
1415 let milestone = a.milestone;
1416 let read = |repo_id: String| {
1417 let label = label.clone();
1418 async move {
1419 let query = self
1420 .db
1421 .prepare(format!(
1422 "SELECT {PULL_COLUMNS} FROM pulls WHERE repo_id = ? {filter}
1423 AND (? IS NULL OR EXISTS
1424 (SELECT 1 FROM json_each(pulls.labels) WHERE json_each.value = ?))
1425 AND (? IS NULL OR milestone = ?)
1426 ORDER BY number DESC LIMIT ?"
1427 ))
1428 .bind(&[
1429 repo_id.into(),
1430 optional(&label),
1431 optional(&label),
1432 optional_number(milestone),
1433 optional_number(milestone),
1434 LIST_PAGE.into(),
1435 ])?;
1436 self.timing.db(1, query.all()).await?.results::<PullRow>()
1437 }
Put the stylesheet at the top of the head, before React Router's preloads1438 };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1439 let (repo, rows) = check!(self.repo_then(&a.repo, &a.viewer, read).await?);
1440 let base = a.base.map(|base| base.trim().to_owned()).filter(|base| !base.is_empty());
1441 Ok(Outcome::Ok(
1442 rows.into_iter()
1443 .map(|row| {
1444 let mut pull = Pull::from(row);
1445 fill_base(&mut pull, &repo);
1446 pull
1447 })
1448 .filter(|pull| base.as_deref().is_none_or(|base| pull.base.as_deref() == Some(base)))
1449 .collect(),
1450 ))
Put the stylesheet at the top of the head, before React Router's preloads1451 }
1452
1453 /// `pulls_for_repos`: what `list_pulls` gives, open and closed, for many
1454 /// repositories at once: one access check with repos for all of them
1455 /// and one query, instead of two of each per repository.
1456 async fn pulls_for_repos(&self, a: PullsForReposArgs) -> Result<Vec<RepoPulls>> {
1457 let ids: Vec<String> = a.repo_ids.into_iter().take(MAX_PULLS_FOR_REPOS).collect();
1458 if ids.is_empty() {
1459 return Ok(Vec::new());
1460 }
1461 let limit = a.limit.clamp(1, LIST_PAGE);
1462 // The rows are read beside the access check, for every id asked
1463 // about; those of repositories the viewer cannot read are dropped.
1464 let asked = serde_json::to_string(&ids)?;
1465 let check = ReadableArgs { ids, viewer: a.viewer };
1466 let readable = self.timing.rpc(g1t_kit::call::<_, Vec<Repo>>(&self.repos, "readable", &check));
1467 let (readable, rows) = try_join(readable, self.timing.db(1, self.newest_pulls(asked, limit))).await?;
1468 if readable.is_empty() {
1469 return Ok(Vec::new());
1470 }
1471 let mut answer: Vec<RepoPulls> = readable
1472 .iter()
1473 .map(|repo| RepoPulls { repo_id: repo.id.clone(), open: Vec::new(), closed: Vec::new() })
1474 .collect();
1475 for pull in rows.into_iter().map(Pull::from) {
1476 let Some(entry) = answer.iter_mut().find(|entry| entry.repo_id == pull.repo_id) else {
1477 continue;
1478 };
1479 match pull.status {
1480 PullStatus::Draft | PullStatus::Open => entry.open.push(pull),
1481 PullStatus::Merged | PullStatus::Closed => entry.closed.push(pull),
1482 }
1483 }
1484 for entry in &mut answer {
1485 entry.open.sort_by_key(|pull| std::cmp::Reverse(pull.number));
1486 entry.closed.sort_by_key(|pull| std::cmp::Reverse(pull.number));
1487 }
1488 Ok(answer)
1489 }
1490
1491 /// The newest `limit` of each repository's open (draft or open) and
1492 /// closed (merged or closed) pull requests, for the ids in `ids` (JSON).
1493 async fn newest_pulls(&self, ids: String, limit: u32) -> Result<Vec<PullRow>> {
1494 self.db
1495 .prepare(format!(
1496 "SELECT * FROM (
1497 SELECT {PULL_COLUMNS}, ROW_NUMBER() OVER (
1498 PARTITION BY pulls.repo_id, pulls.status IN ('draft', 'open') ORDER BY pulls.number DESC
1499 ) AS place
1500 FROM pulls WHERE pulls.repo_id IN (SELECT value FROM json_each(?1))
1501 ) WHERE place <= ?2"
1502 ))
1503 .bind(&[ids.into(), limit.into()])?
1504 .all()
1505 .await?
1506 .results::<PullRow>()
1507 }
1508
1509 async fn get_pull(&self, a: ViewArgs) -> Result<Outcome<PullDetail>> {
1510 let number = a.number;
1511 // Every row the page and the lifecycle read, in one batch started
1512 // beside the access check; the helpers below read from it.
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1513 let namespace = a.repo.namespace.clone();
1514 let read = |repo_id: String| self.prefetch_pull(repo_id, namespace.clone(), number);
Put the stylesheet at the top of the head, before React Router's preloads1515 let Outcome::Ok((repo, Some(found))) = self.repo_then(&a.repo, &a.viewer, read).await? else {
1516 return Ok(no_pull());
1517 };
1518 let Some(row) = found.first::<PullRow>(prefetch::Slot::Pull)? else {
1519 return Ok(no_pull());
1520 };
1521 let stored = found.first::<StoredBehind>(prefetch::Slot::Pull)?;
1522 let issue = found.first::<IssueRow>(prefetch::Slot::Issue)?.map(Issue::from);
1523 let comments: Vec<Comment> =
1524 found.rows::<CommentRow>(prefetch::Slot::Comments)?.into_iter().map(Comment::from).collect();
1525 self.keep_prefetched(Some(found));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1526 let default_branch = repo.default_branch.clone();
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1527 let detail = self.pull_detail(repo, Pull::from(row), issue, comments, stored, &a.viewer).await;
Put the stylesheet at the top of the head, before React Router's preloads1528 self.keep_prefetched(None);
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1529 // The branch it merges into, named, for whoever reads it.
1530 Ok(match detail? {
1531 Outcome::Ok(mut detail) => {
1532 if detail.pull.base.as_deref().is_none_or(str::is_empty) {
1533 detail.pull.base = Some(default_branch);
1534 }
1535 Outcome::Ok(detail)
1536 }
1537 failed => failed,
1538 })
Put the stylesheet at the top of the head, before React Router's preloads1539 }
1540
1541 async fn pull_detail(
1542 &self,
1543 repo: Repo,
1544 mut pull: Pull,
1545 issue: Option<Issue>,
1546 comments: Vec<Comment>,
1547 stored: Option<StoredBehind>,
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1548 viewer: &Viewer,
Put the stylesheet at the top of the head, before React Router's preloads1549 ) -> Result<Outcome<PullDetail>> {
1550 // Whether it is behind, as worked out with its mergeability on the
1551 // last push to either side (mergeability.rs), when that was for
1552 // its head as it is now; otherwise asked of the repos service.
1553 let known_behind = stored.and_then(|stored| stored.for_head(pull.head_commit.as_deref()));
1554 // Worked out on each push; this covers a pull request from before
1555 // that was recorded.
1556 if pull.files.is_empty() && pull.head_commit.is_some() {
1557 pull.files = self.refresh_files(&pull).await?;
1558 }
1559 // Everything else at once: none of it depends on the rest, and each
1560 // is a round trip of its own.
1561 let standing = async {
1562 // Mergeability first: where g1t sees a pull request through, a
1563 // conflict decides its next step.
1564 let behind = async {
1565 match known_behind {
1566 Some(behind) => Ok(behind),
1567 None => {
1568 let behind = self.is_behind(&repo.id, &pull).await?;
1569 // Kept for the next view when the mergeability on
1570 // record is for this head: a pull request from
1571 // before `behind` was kept asks once.
1572 if let Some(head) = pull.head_commit.as_deref()
1573 && pull.status.is_active()
1574 {
1575 self.db
1576 .prepare(
1577 "UPDATE pulls SET behind = ?1
1578 WHERE id = ?2 AND behind IS NULL AND mergeable_key LIKE ?3 || '..%'",
1579 )
1580 .bind(&[u32::from(behind).into(), pull.id.as_str().into(), head.into()])?
1581 .run()
1582 .await?;
1583 }
1584 Ok(behind)
1585 }
1586 }
1587 };
1588 let (merge, behind) = try_join(self.mergeability(&pull), behind).await?;
1589 let assessed = self.assess_with_confidence(&pull, &issue, behind).await?;
1590 let confidence = assessed.as_ref().and_then(|(_, _, confidence)| confidence.clone());
1591 let lifecycle = assessed.map(|(lifecycle, _, _)| lifecycle);
1592 Ok::<_, worker::Error>((behind, (lifecycle, confidence), merge))
1593 };
1594 let (((behind, (lifecycle, confidence), (mergeable, conflicts)), (landing, stalled), comments), (checks, overlaps, review_pending)) =
1595 try_join(
1596 try_join3(standing, self.landing_state(&pull.id), async { Ok(comments) }),
1597 try_join3(
1598 self.latest_checks(&pull.id),
1599 self.overlaps(&pull),
1600 self.review_pending(&pull.id),
1601 ),
1602 )
1603 .await?;
1604 // As just worked out, rather than as it was read.
1605 if confidence.is_some() {
1606 pull.confidence = confidence;
1607 }
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1608 // The rules of the branch it merges into, as they stack, and which
1609 // of them it does not meet yet, for whoever is looking.
1610 let (statuses, settings, gate) = try_join3(
1611 self.statuses(&repo.id, pull.head_commit.as_deref()),
1612 self.settings_on(&repo, &pull),
1613 async {
1614 if pull.status.is_active() {
1615 self.merge_gate(&repo, &pull, viewer.as_ref(), false, true).await.map(Some)
1616 } else {
1617 Ok(None)
1618 }
1619 },
1620 )
1621 .await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1622 let code_owners = self.pull_code_owners(&pull, &comments, &settings).await?;
A required check named for a workflow job, as GitHub's branch rules name them, is met by that job. Each g1t Actions job on a pull request's head counts as a check of its name (`test`, `build (ubuntu-latest, 22)`, or `deploy / build` for a called workflow's job), from the latest attempt of its workflow: skipped passes, cancelled fails, and a job still going holds the merge. So a ruleset or required check copied from GitHub needs no renaming, and one that names the workflow, such as `CI`, is met by the whole run as before. This holds in the merge box, for the merge queue and for agents' merges; jobs count only toward required checks, so a `continue-on-error` job that fails doesn't send an agent back. The pull requests guide says so.1623 let required = if settings.required_checks.is_empty() {
1624 Vec::new()
1625 } else {
1626 let checked = self.with_job_checks(&repo.id, pull.head_commit.as_deref(), statuses.clone()).await?;
1627 required_checks(&settings.required_checks, &checked)
1628 };
Put the stylesheet at the top of the head, before React Router's preloads1629 Ok(Outcome::Ok(PullDetail {
A required check named for a workflow job, as GitHub's branch rules name them, is met by that job. Each g1t Actions job on a pull request's head counts as a check of its name (`test`, `build (ubuntu-latest, 22)`, or `deploy / build` for a called workflow's job), from the latest attempt of its workflow: skipped passes, cancelled fails, and a job still going holds the merge. So a ruleset or required check copied from GitHub needs no renaming, and one that names the workflow, such as `CI`, is met by the whole run as before. This holds in the merge box, for the merge queue and for agents' merges; jobs count only toward required checks, so a `continue-on-error` job that fails doesn't send an agent back. The pull requests guide says so.1630 required_checks: required,
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1631 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 bar1632 code_owners,
Put the stylesheet at the top of the head, before React Router's preloads1633 comments,
1634 checks,
1635 overlaps,
1636 behind,
1637 review_pending,
1638 lifecycle,
1639 landing,
1640 stalled,
1641 messages: self.messages(&pull.id).await?,
1642 statuses,
1643 mergeable,
1644 conflicts,
1645 earlier_checks: self.earlier_checks(&pull.id).await?,
1646 issue,
1647 pull,
1648 }))
1649 }
1650
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1651 /// The pull request, whatever its status, if its repository is not
1652 /// archived and `actor` opened it or may triage its pull requests.
1653 async fn managed_pull(
Put the stylesheet at the top of the head, before React Router's preloads1654 &self,
1655 actor: &User,
1656 path: &RepoPath,
1657 number: u32,
1658 ) -> Result<Outcome<Pull>> {
1659 let (repo, pull) = check!(self.pull_at(path, number, &Some(actor.clone())).await?);
1660 check!(writable(&repo));
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1661 if !pull.is_owned_by(&actor.id) {
Put the stylesheet at the top of the head, before React Router's preloads1662 check!(allowed(Some(actor), &repo, Capability::Triage));
1663 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1664 Ok(Outcome::Ok(pull))
1665 }
1666
1667 /// The pull request, if it is still active and `actor` opened it or
1668 /// may triage the repository's pull requests.
1669 async fn manageable_pull(
1670 &self,
1671 actor: &User,
1672 path: &RepoPath,
1673 number: u32,
1674 ) -> Result<Outcome<Pull>> {
1675 let pull = check!(self.managed_pull(actor, path, number).await?);
Put the stylesheet at the top of the head, before React Router's preloads1676 if !pull.status.is_active() {
1677 return Ok(Outcome::fail(
1678 FailureCode::Conflict,
1679 format!("This pull request is already {}.", pull.status.as_str()),
1680 ));
1681 }
1682 Ok(Outcome::Ok(pull))
1683 }
1684
1685 /// Brings a pull request up to date with the default branch without a
1686 /// 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 rights1687 /// have pushed the merge themselves may ask: whoever opened it (or asked
1688 /// g1t for it), for a fork; anyone who may push, for a branch of the
1689 /// repository. When it needs a
Put the stylesheet at the top of the head, before React Router's preloads1690 /// real merge, says so, naming the conflicting files if a probe found
1691 /// them, and pushes nothing.
1692 async fn catch_up_pull(&self, a: PullActionArgs) -> Result<Outcome<PullBranchUpdate>> {
1693 let (repo, pull) = check!(self.pull_at(&a.repo, a.number, &Some(a.actor.clone())).await?);
1694 check!(writable(&repo));
1695 if !pull.status.is_active() {
1696 return Ok(Outcome::fail(
1697 FailureCode::Conflict,
1698 format!("This pull request is already {}.", pull.status.as_str()),
1699 ));
1700 }
1701 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 rights1702 if !pull.is_owned_by(&a.actor.id) {
Put the stylesheet at the top of the head, before React Router's preloads1703 return Ok(Outcome::fail(
1704 FailureCode::Forbidden,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1705 "Only whoever opened this pull request, or asked g1t for it, can update it.",
Put the stylesheet at the top of the head, before React Router's preloads1706 ));
1707 }
1708 } else {
1709 check!(allowed(Some(&a.actor), &repo, Capability::Push));
1710 }
1711 let updated: Outcome<PullBranchUpdate> = g1t_kit::call(
1712 &self.repos,
1713 "update_pull_branch",
1714 &UpdatePullBranchArgs {
1715 source_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.id.clone()),
1716 branch: pull.branch.clone(),
1717 number: pull.number,
1718 actor: a.actor,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1719 target_branch: pull.base.clone(),
Put the stylesheet at the top of the head, before React Router's preloads1720 },
1721 )
1722 .await?;
1723 // A probe that found conflicts says more than "both changed it".
1724 if let Outcome::Ok(PullBranchUpdate::NeedsAgent { .. }) = &updated
1725 && let Some(files) = self.conflicting_files(&pull).await?
1726 && !files.is_empty()
1727 {
1728 return Ok(Outcome::Ok(PullBranchUpdate::NeedsAgent {
1729 reason: NeedsAgentReason::Conflicting,
1730 detail: "Merging it conflicts.".to_owned(),
1731 paths: files,
1732 }));
1733 }
1734 Ok(updated)
1735 }
1736
1737 async fn update_pull(&self, a: UpdatePullArgs) -> Result<Outcome<Pull>> {
1738 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 bar1739 // The branch it merges into, its milestone and its labels first:
1740 // each can be refused, and then nothing else changes.
1741 if a.base.is_some() || a.milestone.is_some() || a.labels.is_some() {
1742 let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?);
1743 if let Some(base) = &a.base {
1744 check!(self.change_base(&a.actor, &repo, &pull, base).await?);
1745 }
1746 if let Some(number) = a.milestone {
1747 check!(self.set_milestone(&a.actor, &repo, &labels::Item::Pull(pull.clone()), number).await?);
1748 }
1749 if let Some(labels) = &a.labels {
1750 check!(self.relabel(&a.actor, &repo, &labels::Item::Pull(pull.clone()), labels).await?);
1751 }
1752 }
Put the stylesheet at the top of the head, before React Router's preloads1753 let assignees = match a.assignees {
1754 Some(names) => Some(check!(self.valid_assignees(names).await?)),
1755 None => None,
1756 };
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1757 // Teams, named `workspace/team`, apart from the people.
1758 let (team_names, a_reviewers) = match a.reviewers {
1759 Some(names) => {
1760 let (teams, people): (Vec<String>, Vec<String>) =
1761 names.into_iter().partition(|name| team_reviews::team_name(name).is_some());
1762 (Some(teams), Some(people))
1763 }
1764 None => (None, None),
1765 };
1766 let teams = match team_names {
1767 Some(names) => Some(check!(self.valid_team_reviewers(&a.actor, &a.repo, &pull, names).await?)),
1768 None => None,
1769 };
1770 let reviewers = match a_reviewers {
Put the stylesheet at the top of the head, before React Router's preloads1771 Some(names) => {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1772 // g1t is not an account; everyone else has to be.
Put the stylesheet at the top of the head, before React Router's preloads1773 let agent = names
1774 .iter()
1775 .any(|name| name.trim().eq_ignore_ascii_case(reviews::AGENT_NAME));
1776 let people = names
1777 .into_iter()
1778 .filter(|name| !name.trim().eq_ignore_ascii_case(reviews::AGENT_NAME))
1779 .collect();
1780 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 rights1781 // Nobody is asked to review their own, nor what they had g1t make.
1782 reviewers.retain(|name| *name != pull.owner().username);
Put the stylesheet at the top of the head, before React Router's preloads1783 if agent {
1784 reviewers.insert(0, reviews::AGENT_NAME.to_owned());
1785 }
1786 Some(reviewers)
1787 }
1788 None => None,
1789 };
1790 self.db
1791 .prepare(
1792 "UPDATE pulls
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1793 SET assignees = COALESCE(?, assignees), updated_at = ?
Put the stylesheet at the top of the head, before React Router's preloads1794 WHERE id = ?",
1795 )
1796 .bind(&[
1797 optional(&assignees.as_ref().map(serde_json::to_string).transpose()?),
1798 rfc3339(now_ms()).into(),
1799 pull.id.as_str().into(),
1800 ])?
1801 .run()
1802 .await?;
1803 if let Some(assignees) = &assignees {
1804 self.note_changes(
1805 &pull.repo_id,
1806 pull.number,
1807 &a.actor,
1808 &pull.assignees,
1809 assignees,
1810 ("assigned", "unassigned"),
1811 )
1812 .await?;
1813 }
Events: review requests, assignments, stops and deployments are published1814 // Who was newly assigned or asked to review, and whose request was
1815 // withdrawn: the inbox tells them, and webhooks say so.
1816 let newly = |after: &[String], before: &[String]| -> Vec<String> {
1817 after.iter().filter(|name| !before.contains(name)).cloned().collect()
1818 };
1819 if let Some(assignees) = &assignees {
1820 let added = newly(assignees, &pull.assignees);
1821 if !added.is_empty() {
1822 self.publish(
1823 "pull.assigned",
1824 &pull.repo_id,
1825 &a.actor,
1826 PullEvent {
1827 assignees: Some(assignees.clone()),
1828 added: Some(added),
1829 ..Self::pull_event(&pull)
1830 },
1831 )
1832 .await?;
1833 }
1834 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1835 if reviewers.is_some() || teams.is_some() {
1836 let people = reviewers.unwrap_or_else(|| pull.reviewers.clone());
1837 let teams = teams.unwrap_or_else(|| pull.team_reviewers.clone());
1838 self.set_reviewers(&pull, people, teams, Some(&a.actor), false).await?;
Events: review requests, assignments, stops and deployments are published1839 }
Put the stylesheet at the top of the head, before React Router's preloads1840 Ok(match self.pull(&pull.repo_id, pull.number).await? {
1841 Some(pull) => Outcome::Ok(pull),
1842 None => no_pull(),
1843 })
1844 }
1845
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1846 /// Points an open pull request at another branch to merge into. Needs
1847 /// the Write role. What it would merge, whether it is behind, its
1848 /// mergeability and its checks are all worked out against the new base.
1849 async fn change_base(&self, actor: &User, repo: &Repo, pull: &Pull, base: &str) -> Result<Outcome<()>> {
1850 check!(allowed(Some(actor), repo, Capability::Push));
1851 let base = base.trim();
1852 if base.is_empty() {
1853 return Ok(Outcome::fail(FailureCode::Invalid, "Name the branch it should merge into."));
1854 }
1855 let before = pull.base_branch(&repo.default_branch).to_owned();
1856 if base == before {
1857 return Ok(Outcome::Ok(()));
1858 }
1859 if pull.fork_repo_id.is_none() && pull.branch.as_deref() == Some(base) {
1860 return Ok(Outcome::fail(
1861 FailureCode::Invalid,
1862 format!("A pull request cannot merge {base} into itself. Choose another base."),
1863 ));
1864 }
1865 let exists: Option<String> = g1t_kit::call(
1866 &self.repos,
1867 "head",
1868 &HeadArgs { repo_id: repo.id.clone(), branch: base.to_owned() },
1869 )
1870 .await?;
1871 if exists.is_none() {
1872 return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no branch named {base} to merge into.")));
1873 }
1874 // In the merge queue it was headed for the default branch; it
1875 // leaves the queue for another base.
1876 let left_queue = self
1877 .leave(&pull.repo_id, pull, QueueState::Removed, Some("Its base branch changed."))
1878 .await?;
1879 let stored = stored_base(base, repo);
1880 self.db
1881 .prepare(
1882 "UPDATE pulls
1883 SET base_branch = ?, updated_at = ?, land_requested = NULL, land_requested_at = NULL, behind = NULL,
1884 mergeable = NULL, mergeable_key = NULL, conflicts = NULL
1885 WHERE id = ?",
1886 )
1887 .bind(&[optional(&stored), rfc3339(now_ms()).into(), pull.id.as_str().into()])?
1888 .run()
1889 .await?;
1890 self.note(
1891 &pull.repo_id,
1892 pull.number,
1893 (&actor.id, &actor.username),
1894 &format!("changed the base branch from `{before}` to `{base}`"),
1895 )
1896 .await?;
1897 self.publish(
1898 "pull.base_changed",
1899 &pull.repo_id,
1900 actor,
1901 PullEvent { base: Some(base.to_owned()), ..Self::pull_event(pull) },
1902 )
1903 .await?;
1904 if left_queue {
1905 self.publish_as(
1906 "queue.changed",
1907 &pull.repo_id,
1908 None,
1909 g1t_contracts::events::QueueChanged { repo_id: pull.repo_id.clone() },
1910 )
1911 .await?;
1912 }
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'1913 // Its merge ref, and whether it merges cleanly, against the new base.
1914 if let Some(moved) = self.pull_by_id(&pull.id).await? {
1915 self.keep_pull_refs(&moved).await;
1916 if let Err(error) = self.assess_mergeability(&moved).await {
1917 worker::console_warn!("mergeability of {}: {error}", pull.id);
1918 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1919 }
1920 Ok(Outcome::Ok(()))
1921 }
1922
Put the stylesheet at the top of the head, before React Router's preloads1923 /// Marks a draft ready for review, or updates the description of one
1924 /// that already is.
1925 async fn ready_pull(&self, a: PullActionArgs) -> Result<Outcome<Pull>> {
1926 let mut pull = check!(self.manageable_pull(&a.actor, &a.repo, a.number).await?);
1927 let summary = Some(a.summary.trim().to_owned()).filter(|summary| !summary.is_empty());
1928 let now = rfc3339(now_ms());
1929 self.db
1930 .prepare(
1931 "UPDATE pulls SET status = 'open', body = COALESCE(?, body), updated_at = ?
1932 WHERE id = ?",
1933 )
1934 .bind(&[
1935 optional(&summary),
1936 now.as_str().into(),
1937 pull.id.as_str().into(),
1938 ])?
1939 .run()
1940 .await?;
1941 if pull.status == PullStatus::Draft {
1942 // The head as it is now: the push that came just before may not
1943 // have reached `head_commit` yet, and workflows run on it.
1944 let commit = self.live_head(&pull).await?.or_else(|| pull.head_commit.clone());
1945 self.publish(
1946 "pull.ready",
1947 &pull.repo_id,
1948 &a.actor,
1949 PullEvent {
1950 commit,
1951 ..Self::pull_event(&pull)
1952 },
1953 )
1954 .await?;
1955 }
1956 if pull.status == PullStatus::Draft {
1957 self.note(
1958 &pull.repo_id,
1959 pull.number,
1960 (&a.actor.id, &a.actor.username),
1961 "marked this ready for review",
1962 )
1963 .await?;
1964 }
1965 pull.status = PullStatus::Open;
1966 pull.body = summary.or(pull.body);
1967 pull.updated_at = now;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1968 // A draft's code owners are asked once it is ready.
1969 self.refresh_code_owners(&pull).await;
1970 if let Some(fresh) = self.pull(&pull.repo_id, pull.number).await? {
1971 pull.reviewers = fresh.reviewers;
1972 pull.team_reviewers = fresh.team_reviewers;
1973 }
Put the stylesheet at the top of the head, before React Router's preloads1974 Ok(Outcome::Ok(pull))
1975 }
1976
1977 async fn close_pull(&self, a: PullActionArgs) -> Result<Outcome<Pull>> {
1978 let mut pull = check!(self.manageable_pull(&a.actor, &a.repo, a.number).await?);
1979 let now = rfc3339(now_ms());
1980 self.db
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1981 // What it was closed as, so that reopening brings that back.
1982 .prepare("UPDATE pulls SET status = 'closed', closed_from = ?, updated_at = ? WHERE id = ?")
1983 .bind(&[pull.status.as_str().into(), now.as_str().into(), pull.id.as_str().into()])?
Put the stylesheet at the top of the head, before React Router's preloads1984 .run()
1985 .await?;
1986 self.publish(
1987 "pull.closed",
1988 &pull.repo_id,
1989 &a.actor,
1990 Self::pull_event(&pull),
1991 )
1992 .await?;
1993 self.note(
1994 &pull.repo_id,
1995 pull.number,
1996 (&a.actor.id, &a.actor.username),
1997 "closed this",
1998 )
1999 .await?;
2000 // A closed pull request leaves the merge queue.
2001 if self
2002 .leave(&pull.repo_id, &pull, QueueState::Removed, Some("It was closed."))
2003 .await?
2004 {
2005 self.publish_as(
2006 "queue.changed",
2007 &pull.repo_id,
2008 None,
2009 g1t_contracts::events::QueueChanged {
2010 repo_id: pull.repo_id.clone(),
2011 },
2012 )
2013 .await?;
2014 }
2015 pull.status = PullStatus::Closed;
2016 pull.updated_at = now;
2017 Ok(Outcome::Ok(pull))
2018 }
2019
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts2020 /// Opens a closed pull request again: as the draft it was, if it was
2021 /// closed as one, else ready for review. A merged one stays merged.
2022 async fn reopen_pull(&self, a: PullActionArgs) -> Result<Outcome<Pull>> {
2023 let mut pull = check!(self.managed_pull(&a.actor, &a.repo, a.number).await?);
2024 if let Some(refusal) = reopen_refusal(pull.status) {
2025 return Ok(Outcome::fail(FailureCode::Conflict, refusal));
2026 }
2027 // The head as it is now, which workflows run on. A branch of the
2028 // repository that was deleted since leaves nothing to reopen; a
2029 // fork removed after the close is made again (repos, `pull.reopened`).
2030 let head = self.live_head(&pull).await?;
2031 if head.is_none() && pull.fork_repo_id.is_none() {
2032 return Ok(Outcome::fail(
2033 FailureCode::Conflict,
2034 format!(
2035 "The branch {} no longer exists. Push it again to reopen this pull request.",
2036 pull.branch.as_deref().unwrap_or_default()
2037 ),
2038 ));
2039 }
2040 let closed_from: Option<ClosedFrom> = self
2041 .db
2042 .prepare("SELECT closed_from FROM pulls WHERE id = ?")
2043 .bind(&[pull.id.as_str().into()])?
2044 .first(None)
2045 .await?;
2046 let status = reopened_status(closed_from.and_then(|row| row.closed_from).as_deref());
2047 let now = rfc3339(now_ms());
2048 self.db
2049 .prepare("UPDATE pulls SET status = ?, closed_from = NULL, superseded_by = NULL, updated_at = ? WHERE id = ?")
2050 .bind(&[status.as_str().into(), now.as_str().into(), pull.id.as_str().into()])?
2051 .run()
2052 .await?;
2053 self.publish(
2054 "pull.reopened",
2055 &pull.repo_id,
2056 &a.actor,
2057 PullEvent {
2058 commit: head.or_else(|| pull.head_commit.clone()),
2059 ..Self::pull_event(&pull)
2060 },
2061 )
2062 .await?;
2063 self.note(
2064 &pull.repo_id,
2065 pull.number,
2066 (&a.actor.id, &a.actor.username),
2067 "reopened this",
2068 )
2069 .await?;
2070 pull.status = status;
2071 pull.superseded_by = None;
2072 pull.updated_at = now;
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'2073 // Its refs in the repository, and whether it still merges cleanly,
2074 // now that it is open again.
2075 self.keep_pull_refs(&pull).await;
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts2076 if let Err(error) = self.assess_mergeability(&pull).await {
2077 worker::console_warn!("mergeability of {}: {error}", pull.id);
2078 }
2079 Ok(Outcome::Ok(pull))
2080 }
2081
2082 /// Turns a pull request that is ready for review back into a draft: it
2083 /// cannot be merged until it is marked ready again, and it leaves the
2084 /// merge queue and any merge that was waiting for it to catch up.
2085 async fn convert_pull_to_draft(&self, a: PullActionArgs) -> Result<Outcome<Pull>> {
2086 let mut pull = check!(self.managed_pull(&a.actor, &a.repo, a.number).await?);
2087 if let Some(refusal) = draft_refusal(pull.status) {
2088 return Ok(Outcome::fail(FailureCode::Conflict, refusal));
2089 }
2090 let now = rfc3339(now_ms());
2091 self.db
2092 .prepare(
2093 "UPDATE pulls SET status = 'draft', land_requested = NULL, land_requested_at = NULL, updated_at = ?
2094 WHERE id = ?",
2095 )
2096 .bind(&[now.as_str().into(), pull.id.as_str().into()])?
2097 .run()
2098 .await?;
2099 self.publish(
2100 "pull.converted_to_draft",
2101 &pull.repo_id,
2102 &a.actor,
2103 Self::pull_event(&pull),
2104 )
2105 .await?;
2106 self.note(
2107 &pull.repo_id,
2108 pull.number,
2109 (&a.actor.id, &a.actor.username),
2110 "marked this as a draft",
2111 )
2112 .await?;
2113 if self
2114 .leave(&pull.repo_id, &pull, QueueState::Removed, Some("It was marked as a draft."))
2115 .await?
2116 {
2117 self.publish_as(
2118 "queue.changed",
2119 &pull.repo_id,
2120 None,
2121 g1t_contracts::events::QueueChanged {
2122 repo_id: pull.repo_id.clone(),
2123 },
2124 )
2125 .await?;
2126 }
2127 pull.status = PullStatus::Draft;
2128 pull.updated_at = now;
2129 Ok(Outcome::Ok(pull))
2130 }
2131
Put the stylesheet at the top of the head, before React Router's preloads2132 /// Lands the pull request on the repository's default branch. Unless
2133 /// told to keep it open, that resolves the issue it was for: the issue
2134 /// closes naming this pull request, and the others still in progress
2135 /// for it close as superseded.
2136 async fn merge_pull(&self, a: PullActionArgs) -> Result<Outcome<Pull>> {
2137 let viewer = Some(a.actor.clone());
2138 let (repo, pull) = check!(self.pull_at(&a.repo, a.number, &viewer).await?);
2139 check!(writable(&repo));
2140 match pull.status {
2141 PullStatus::Open => {}
2142 PullStatus::Draft => {
2143 return Ok(Outcome::fail(
2144 FailureCode::Conflict,
2145 "This pull request is still a draft. Mark it ready for review first.",
2146 ));
2147 }
2148 status => {
2149 return Ok(Outcome::fail(
2150 FailureCode::Conflict,
2151 format!("This pull request is already {}.", status.as_str()),
2152 ));
2153 }
2154 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2155 let base = pull.base_branch(&repo.default_branch).to_owned();
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2156 // The rules of the branch it merges into, as they stack: the one
2157 // gate for the merge button, the API, MCP, auto-merge and the queue,
2158 // for a person's pull request and an agent's alike. A bypass counts
2159 // when the merger asks for it, or for g1t when a ruleset lists it.
2160 let gate = self.merge_gate(&repo, &pull, Some(&a.actor), a.ignore_checks, true).await?;
2161 let bypassable = gate.bypassable();
2162 let gate = if a.bypass_rules || a.actor.is_system() { gate } else { gate.without_bypass() };
2163 let settings = rulesets::overlay(self.settings(&repo.id).await?, &gate.requirements, base == repo.default_branch);
2164 if pull.check_status == Some(CheckStatus::Failed) && !(a.ignore_checks && settings.allow_ignoring_checks) {
2165 return Ok(Outcome::fail(
2166 FailureCode::Conflict,
2167 "It failed in the merge queue; push a fix to try again.",
2168 ));
2169 }
2170 if let Some(refusal) = gate.refusal() {
2171 if access::can(Some(&a.actor), &repo, Capability::Merge) {
2172 self.record_merge_evaluations(&repo, &pull, &gate).await;
Put the stylesheet at the top of the head, before React Router's preloads2173 }
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2174 let offer = if bypassable && !a.bypass_rules {
2175 " You may bypass these rules: merge again and ask to bypass them (bypass_rules)."
2176 } else {
2177 ""
2178 };
2179 return Ok(Outcome::fail(FailureCode::Conflict, format!("{refusal}{offer}")));
Put the stylesheet at the top of the head, before React Router's preloads2180 }
2181 // Known ahead of time to conflict: neither a merge nor the queue
2182 // would get through, so say what has to be resolved now.
2183 if let Some(files) = self.conflicting_files(&pull).await? {
2184 let named = if files.is_empty() {
2185 String::new()
2186 } else {
2187 format!(" in {}", files.join(", "))
2188 };
2189 return Ok(Outcome::fail(
2190 FailureCode::Conflict,
2191 format!(
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2192 "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."
Put the stylesheet at the top of the head, before React Router's preloads2193 ),
2194 ));
2195 }
2196
2197 // A repository that merges through a queue: it joins the queue, and
2198 // lands once its state together with everything ahead has passed.
2199 if settings.merge_queue {
2200 if !a.actor.verified {
2201 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
2202 }
2203 check!(allowed(Some(&a.actor), &repo, Capability::Merge));
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2204 self.record_merge_evaluations(&repo, &pull, &gate).await;
Put the stylesheet at the top of the head, before React Router's preloads2205 return self.enqueue(&repo, &pull, &a.actor, a.keep_issue_open).await;
2206 }
2207
2208 // The default branch has moved under it. Unless the repository
2209 // insists on that being dealt with first, bring it up to date and
2210 // land it when that is done.
2211 if self.is_behind(&repo.id, &pull).await? {
2212 if settings.require_up_to_date {
2213 return Ok(Outcome::fail(
2214 FailureCode::Conflict,
2215 format!(
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2216 "{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."
Put the stylesheet at the top of the head, before React Router's preloads2217 ),
2218 ));
2219 }
2220 if !a.actor.verified {
2221 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
2222 }
2223 check!(allowed(Some(&a.actor), &repo, Capability::Merge));
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2224 self.record_merge_evaluations(&repo, &pull, &gate).await;
Put the stylesheet at the top of the head, before React Router's preloads2225 self.request_landing(&pull, &a.actor, a.keep_issue_open)
2226 .await?;
2227 return Ok(Outcome::Ok(pull));
2228 }
2229
2230 // Whether the actor may write to the repository is decided by repos.
2231 let landed: Outcome<Landed> = g1t_kit::call(
2232 &self.repos,
2233 "land",
2234 &LandArgs {
2235 // A pull request from a branch lands from the repository itself.
2236 source_id: pull.fork_repo_id.clone().unwrap_or_else(|| repo.id.clone()),
2237 branch: pull.branch.clone(),
2238 actor: a.actor.clone(),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2239 target_branch: Some(base.clone()),
Put the stylesheet at the top of the head, before React Router's preloads2240 },
2241 )
2242 .await?;
2243 let landed = check!(landed);
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2244 self.record_merge_evaluations(&repo, &pull, &gate).await;
Put the stylesheet at the top of the head, before React Router's preloads2245 Ok(Outcome::Ok(
2246 self.record_merge(&repo, pull, &a.actor, a.keep_issue_open, landed)
2247 .await?,
2248 ))
2249 }
2250
2251 /// Records a pull request as merged once the default branch holds it:
2252 /// closes its issue, supersedes the others for it, and says so.
2253 pub(crate) async fn record_merge(
2254 &self,
2255 repo: &Repo,
2256 mut pull: Pull,
2257 actor: &User,
2258 keep_issue_open: bool,
2259 landed: Landed,
2260 ) -> Result<Pull> {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2261 // Only a merge into the default branch resolves the issue: into
2262 // another branch, the work has not landed yet.
2263 let keep_issue_open = keep_issue_open || !pull.targets_default(&repo.default_branch);
Put the stylesheet at the top of the head, before React Router's preloads2264 let issue = match pull.issue {
2265 Some(number) if !keep_issue_open => self
2266 .issue(&repo.id, number)
2267 .await?
2268 .filter(|issue| issue.state == State::Open),
2269 _ => None,
2270 };
2271 let now = rfc3339(now_ms());
2272 let mut statements = vec![
2273 self.db
2274 .prepare(
2275 "UPDATE pulls
2276 SET status = 'merged', head_commit = ?, merge_base = ?, merged_by = ?,
2277 merged_at = ?, updated_at = ?
2278 WHERE id = ?",
2279 )
2280 .bind(&[
2281 landed.commit.as_str().into(),
2282 optional(&landed.previous),
2283 actor.username.as_str().into(),
2284 now.as_str().into(),
2285 now.as_str().into(),
2286 pull.id.as_str().into(),
2287 ])?,
2288 ];
2289 if let Some(issue) = &issue {
2290 statements.push(
2291 self.db
2292 .prepare(
2293 "UPDATE issues
2294 SET state = 'closed', reason = 'completed', resolved_by = ?,
2295 closed_at = ?, updated_at = ?
2296 WHERE id = ?",
2297 )
2298 .bind(&[
2299 pull.number.into(),
2300 now.as_str().into(),
2301 now.as_str().into(),
2302 issue.id.as_str().into(),
2303 ])?,
2304 );
2305 statements.push(
2306 self.db
2307 .prepare(
2308 "UPDATE pulls SET status = 'closed', superseded_by = ?, updated_at = ?
2309 WHERE issue_id = ? AND id != ? AND status IN ('draft', 'open')",
2310 )
2311 .bind(&[
2312 pull.number.into(),
2313 now.as_str().into(),
2314 issue.id.as_str().into(),
2315 pull.id.as_str().into(),
2316 ])?,
2317 );
2318 }
2319 self.db.batch(statements).await?;
2320
2321 self.publish(
2322 "pull.merged",
2323 &repo.id,
2324 actor,
2325 PullEvent {
2326 commit: Some(landed.commit.clone()),
2327 ..Self::pull_event(&pull)
2328 },
2329 )
2330 .await?;
2331 if let Some(issue) = &issue {
2332 self.publish(
2333 "issue.closed",
2334 &repo.id,
2335 actor,
2336 IssueEvent {
2337 reason: Some(IssueReason::Completed.as_str()),
2338 resolved_by: Some(pull.number),
2339 ..Self::issue_event(issue)
2340 },
2341 )
2342 .await?;
2343 }
2344
2345 let who = (actor.id.as_str(), actor.username.as_str());
2346 self.note(&repo.id, pull.number, who, "merged this").await?;
2347 if let Some(issue) = &issue {
2348 self.note(
2349 &repo.id,
2350 issue.number,
2351 who,
2352 &format!("closed this by merging #{}", pull.number),
2353 )
2354 .await?;
2355 }
2356 pull.status = PullStatus::Merged;
2357 pull.head_commit = Some(landed.commit.clone());
2358 pull.merge_base = landed.previous;
2359 pull.merged_by = Some(actor.username.clone());
2360 pull.merged_at = Some(now.clone());
2361 pull.updated_at = now;
2362 Ok(pull)
2363 }
2364
2365 async fn list_active_pulls(&self, a: ViewerArgs) -> Result<Vec<ActivePull>> {
2366 let Some(viewer) = a.viewer else {
2367 return Ok(Vec::new());
2368 };
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights2369 // The pull requests and their issues, in one round trip: their own,
2370 // and those g1t made for them (Pull::owner).
Put the stylesheet at the top of the head, before React Router's preloads2371 let author = [JsValue::from(viewer.id.as_str())];
2372 let found = self
2373 .timing
2374 .db(
2375 2,
2376 self.db.batch(vec![
2377 self.db
2378 .prepare(format!(
2379 "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 rights2380 WHERE COALESCE(requested_by_id, author_id) = ?1 AND status IN ('draft', 'open')
Put the stylesheet at the top of the head, before React Router's preloads2381 ORDER BY updated_at DESC LIMIT 50"
2382 ))
2383 .bind(&author)?,
2384 self.db
2385 .prepare(format!(
2386 "SELECT {ISSUE_COLUMNS} FROM issues WHERE issues.id IN (
2387 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 rights2388 WHERE COALESCE(requested_by_id, author_id) = ?1 AND status IN ('draft', 'open') AND issue_id IS NOT NULL
Put the stylesheet at the top of the head, before React Router's preloads2389 ORDER BY updated_at DESC LIMIT 50)"
2390 ))
2391 .bind(&author)?,
2392 ]),
2393 )
2394 .await?;
2395 let (Some(found), Some(issues)) = (found.first(), found.get(1)) else {
2396 return Ok(Vec::new());
2397 };
2398 let snapshots = found.results::<Snapshot>()?;
2399 let pulls: Vec<Pull> = found.results::<PullRow>()?.into_iter().map(Pull::from).collect();
2400 let issues: Vec<Issue> = issues.results::<IssueRow>()?.into_iter().map(Issue::from).collect();
2401 let issues = &issues;
2402 // Where each stands: the remembered assessment when there is one,
2403 // and worked out otherwise.
2404 try_join_all(pulls.into_iter().zip(snapshots).map(|(pull, snapshot)| async move {
2405 let issue = pull.issue.and_then(|number| {
2406 issues
2407 .iter()
2408 .find(|issue| issue.repo_id == pull.repo_id && issue.number == number)
2409 .cloned()
2410 });
2411 // Only a pull request g1t is seeing through has a lifecycle.
2412 let lifecycle = if !lifecycle::made_by_g1t(&pull) || snapshot.managed == 0 {
2413 None
2414 } else if let (Some(stage), Some(detail)) = (snapshot.stage, snapshot.stage_detail) {
2415 Some(Lifecycle {
2416 stage,
2417 detail,
2418 revisions: snapshot.revisions,
2419 })
2420 } else {
2421 let behind = self.is_behind(&pull.repo_id, &pull).await?;
2422 self.assess(&pull, &issue, behind)
2423 .await?
2424 .map(|(lifecycle, _)| lifecycle)
2425 };
2426 Ok::<_, worker::Error>(ActivePull {
2427 pull,
2428 issue,
2429 lifecycle,
2430 })
2431 }))
2432 .await
2433 }
2434
2435 // --- Sessions ----------------------------------------------------------
2436
2437 async fn append_session(&self, a: AppendSessionArgs) -> Result<Outcome<Appended>> {
2438 if a.entries.is_empty() {
2439 return Ok(Outcome::Ok(Appended { count: 0 }));
2440 }
2441 if a.entries.len() > MAX_ENTRY_BATCH {
2442 return Ok(Outcome::fail(
2443 FailureCode::Invalid,
2444 format!("Send at most {MAX_ENTRY_BATCH} entries at a time."),
2445 ));
2446 }
2447 let viewer = Some(a.actor.clone());
2448 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 rights2449 if !pull.is_owned_by(&a.actor.id) {
Put the stylesheet at the top of the head, before React Router's preloads2450 return Ok(Outcome::fail(
2451 FailureCode::Forbidden,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights2452 "Only whoever opened a pull request, or asked g1t for it, can record its session.",
Put the stylesheet at the top of the head, before React Router's preloads2453 ));
2454 }
2455
2456 let now = rfc3339(now_ms());
2457 let count = a.entries.len() as u32;
2458 let mut statements = Vec::with_capacity(a.entries.len() + 1);
2459 for entry in a.entries {
2460 let kind = serde_json::to_value(entry.kind)?;
2461 let text: String = entry.text.chars().take(MAX_ENTRY_CHARS).collect();
2462 // Each insert takes the next sequence number itself, so two
2463 // writers appending at once cannot collide.
2464 statements.push(
2465 self.db
2466 .prepare(
2467 "INSERT INTO session_entries (pull_id, seq, kind, text, tool, \"commit\", at)
2468 SELECT ?, COALESCE(MAX(seq), 0) + 1, ?, ?, ?, ?, ?
2469 FROM session_entries WHERE pull_id = ?",
2470 )
2471 .bind(&[
2472 pull.id.as_str().into(),
2473 kind.as_str().unwrap_or("note").into(),
2474 text.into(),
2475 optional(&entry.tool),
2476 optional(&entry.commit.or_else(|| pull.head_commit.clone())),
2477 now.as_str().into(),
2478 pull.id.as_str().into(),
2479 ])?,
2480 );
2481 }
2482 statements.push(
2483 self.db
2484 .prepare("UPDATE pulls SET updated_at = ? WHERE id = ?")
2485 .bind(&[now.as_str().into(), pull.id.as_str().into()])?,
2486 );
2487 self.db.batch(statements).await?;
2488 self.publish(
2489 "session.appended",
2490 &pull.repo_id,
2491 &a.actor,
2492 SessionAppended {
2493 pull_id: pull.id.clone(),
2494 repo_id: pull.repo_id.clone(),
2495 number: pull.number,
2496 count,
2497 },
2498 )
2499 .await?;
2500 Ok(Outcome::Ok(Appended { count }))
2501 }
2502
2503 /// Adds entries to a pull request's session, each taking the next
2504 /// sequence number, without announcing it.
2505 pub(crate) async fn append_entries(&self, pull: &Pull, entries: &[NewSessionEntry]) -> Result<()> {
2506 let now = rfc3339(now_ms());
2507 let mut statements = Vec::with_capacity(entries.len());
2508 for entry in entries {
2509 let kind = serde_json::to_value(entry.kind)?;
2510 let text: String = entry.text.chars().take(MAX_ENTRY_CHARS).collect();
2511 statements.push(
2512 self.db
2513 .prepare(
2514 "INSERT INTO session_entries (pull_id, seq, kind, text, tool, \"commit\", at)
2515 SELECT ?, COALESCE(MAX(seq), 0) + 1, ?, ?, ?, ?, ?
2516 FROM session_entries WHERE pull_id = ?",
2517 )
2518 .bind(&[
2519 pull.id.as_str().into(),
2520 kind.as_str().unwrap_or("note").into(),
2521 text.into(),
2522 optional(&entry.tool),
2523 optional(&entry.commit.clone().or_else(|| pull.head_commit.clone())),
2524 now.as_str().into(),
2525 pull.id.as_str().into(),
2526 ])?,
2527 );
2528 }
2529 self.db.batch(statements).await?;
2530 Ok(())
2531 }
2532
2533 async fn read_session(&self, a: ViewArgs) -> Result<Outcome<Vec<SessionEntry>>> {
2534 let (_, pull) = check!(self.pull_at(&a.repo, a.number, &a.viewer).await?);
2535 let rows = self
2536 .db
2537 .prepare(
2538 "SELECT seq, kind, text, tool, \"commit\", at FROM session_entries
2539 WHERE pull_id = ? AND seq > ? ORDER BY seq LIMIT ?",
2540 )
2541 .bind(&[pull.id.into(), a.after_seq.into(), SESSION_PAGE.into()])?
2542 .all()
2543 .await?
2544 .results::<SessionRow>()?;
2545 Ok(Outcome::Ok(
2546 rows.into_iter().map(SessionEntry::from).collect(),
2547 ))
2548 }
2549
2550 /// A push moves the head of the pull request it concerns: the one whose
2551 /// fork was pushed to, or the one opened from the branch that moved.
2552 async fn on_event(&self, event: &Event) -> Result<()> {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2553 // A new repository starts with the default labels.
2554 if event.kind == "repo.created"
2555 && let Some(repo_id) = event.repo_id.as_deref()
2556 {
2557 return self.seed_labels(repo_id).await;
2558 }
2559 // Pull requests into the branch that became the default merge into
2560 // the default branch, which is stored as none.
2561 if event.kind == "repo.default_branch_changed"
2562 && let (Some(repo_id), Some(to)) = (event.repo_id.as_deref(), event.data["to"].as_str())
2563 {
2564 self.db
2565 .prepare(
2566 "UPDATE pulls SET base_branch = NULL
2567 WHERE repo_id = ? AND base_branch = ? AND status IN ('draft', 'open')",
2568 )
2569 .bind(&[repo_id.into(), to.into()])?
2570 .run()
2571 .await?;
2572 return Ok(());
2573 }
Put the stylesheet at the top of the head, before React Router's preloads2574 if event.kind != "git.push" {
2575 return Ok(());
2576 }
2577 let (Some(repo_id), Some(after), Some(git_ref)) = (
2578 event.repo_id.as_deref(),
2579 event.data["after"].as_str(),
2580 event.data["ref"].as_str(),
2581 ) else {
2582 return Ok(());
2583 };
2584 let now = rfc3339(now_ms());
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2585 // Who moved it, for rules about the most recent push.
2586 let pusher: JsValue = event.actor.as_deref().map_or(JsValue::NULL, Into::into);
Put the stylesheet at the top of the head, before React Router's preloads2587 // The head moved, so whatever the checks said no longer applies, and
2588 // whatever step g1t was waiting on has been taken.
2589 let moved = "UPDATE pulls
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2590 SET head_commit = ?, updated_at = ?, head_pushed_by = ?, head_pushed_at = ?, check_status = NULL, check_run_id = NULL,
Put the stylesheet at the top of the head, before React Router's preloads2591 working_on = NULL, working_until = NULL, stalled = NULL";
2592 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 rights2593 let returning =
2594 "RETURNING id, repo_id, number, issue_number, status, author_id, author_name, requested_by_id, requested_by_name";
Put the stylesheet at the top of the head, before React Router's preloads2595 let mut pulls: Vec<MovedRow> = Vec::new();
2596 // A fork carries its pull request on its default branch.
2597 if event.data["defaultBranch"].as_bool() == Some(true) {
2598 pulls.extend(
2599 self.db
2600 .prepare(format!(
2601 "{moved} WHERE fork_repo_id = ? AND {active} {returning}"
2602 ))
2603 .bind(&[
2604 after.into(),
2605 now.as_str().into(),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2606 pusher.clone(),
2607 now.as_str().into(),
Put the stylesheet at the top of the head, before React Router's preloads2608 repo_id.into(),
2609 after.into(),
2610 ])?
2611 .all()
2612 .await?
2613 .results::<MovedRow>()?,
2614 );
2615 }
2616 if let Some(branch) = git_ref.strip_prefix("refs/heads/") {
2617 pulls.extend(
2618 self.db
2619 .prepare(format!(
2620 "{moved} WHERE repo_id = ? AND source_branch = ? AND {active} {returning}"
2621 ))
2622 .bind(&[
2623 after.into(),
2624 now.as_str().into(),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2625 pusher.clone(),
2626 now.as_str().into(),
Put the stylesheet at the top of the head, before React Router's preloads2627 repo_id.into(),
2628 branch.into(),
2629 after.into(),
2630 ])?
2631 .all()
2632 .await?
2633 .results::<MovedRow>()?,
2634 );
2635 }
2636 // What each now changes, so overlaps show while the work is under way.
2637 for moved in &pulls {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2638 if let Some(mut pull) = self.pull_by_id(&moved.id).await? {
2639 pull.files = self.refresh_files(&pull).await?;
2640 // Owners of files it now changes are asked too.
2641 self.refresh_code_owners(&pull).await;
Put the stylesheet at the top of the head, before React Router's preloads2642 }
2643 }
2644 // A merge that was waiting for this push to bring it up to date.
2645 for moved in &pulls {
2646 self.land_if_requested(&moved.id).await?;
2647 }
2648 // Whether each still merges cleanly, and, when a default branch
2649 // moved, every open pull request into it.
2650 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 bar2651 let into = match event.data["defaultBranch"].as_bool() {
2652 Some(true) => mergeability::Moved::DefaultBranch,
2653 _ => match git_ref.strip_prefix("refs/heads/") {
2654 Some(branch) => mergeability::Moved::Branch(branch),
2655 None => mergeability::Moved::Nothing,
2656 },
2657 };
2658 self.after_push(repo_id, into, &moved_ids).await;
Put the stylesheet at the top of the head, before React Router's preloads2659 // A draft is announced when it is marked ready instead.
2660 for pull in pulls
2661 .into_iter()
2662 .filter(|pull| pull.status == PullStatus::Open)
2663 {
2664 self.publish_as(
2665 "pull.updated",
2666 &pull.repo_id,
2667 event.actor.clone(),
2668 PullEvent {
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights2669 author: Some(g1t_contracts::credentials::Principal { id: pull.author_id, username: pull.author_name }),
2670 requested_by: pull
2671 .requested_by_id
2672 .zip(pull.requested_by_name)
2673 .map(|(id, username)| g1t_contracts::credentials::Principal { id, username }),
Put the stylesheet at the top of the head, before React Router's preloads2674 pull_id: pull.id,
2675 repo_id: pull.repo_id.clone(),
2676 number: pull.number,
2677 issue: pull.issue_number,
2678 commit: Some(after.to_owned()),
2679 ..PullEvent::default()
Merge branch 'worktree-agent-a3abfcce648e87dca'2680 }
2681 .carrying(&event.data),
Put the stylesheet at the top of the head, before React Router's preloads2682 )
2683 .await?;
2684 }
2685 Ok(())
2686 }
2687}
2688
Merge branch 'worktree-agent-a3abfcce648e87dca'2689/// An event's data that carries on what caused the event it follows from.
2690trait Carrying: serde::Serialize + Sized {
2691 /// As JSON, marked as a workflow job's doing when `cause` was.
2692 fn carrying(self, cause: &serde_json::Value) -> serde_json::Value {
2693 g1t_contracts::events::carried(self, cause)
2694 }
2695}
2696
2697impl Carrying for PullEvent {}
2698
Put the stylesheet at the top of the head, before React Router's preloads2699fn service(env: &Env) -> Result<Work> {
2700 Ok(Work {
2701 db: env.d1("DB")?,
2702 identity: env.service("IDENTITY")?,
2703 repos: env.service("REPOS")?,
2704 events: env.service("EVENTS")?,
2705 actions: env.service("ACTIONS")?,
2706 timing: g1t_kit::d1::Timing::default(),
2707 prefetched: std::cell::RefCell::new(None),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2708 known_repos: std::cell::RefCell::new(std::collections::HashMap::new()),
Put the stylesheet at the top of the head, before React Router's preloads2709 })
2710}
2711
2712#[event(fetch)]
2713async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
2714 let Some(method) = rpc_method(&request) else {
2715 return Response::error("Not found", 404);
2716 };
2717 // A replica near the caller when it asks for one (crates/kit/src/d1.rs).
2718 let (db, served) = g1t_kit::d1::open(&env, "DB", &request)?;
2719 let body: serde_json::Value = request.json().await?;
2720 let mut work = service(&env)?;
2721 work.db = db;
2722
2723 let answered = match method.as_str() {
2724 "open_issue" => reply(&work.open_issue(args(body)?).await?),
2725 "delegate_issue" => reply(&work.delegate_issue(args(body)?).await?),
2726 "report_confidence" => reply(&work.report_confidence(args(body)?).await?),
2727 "list_issues" => reply(&work.list_issues(args(body)?).await?),
2728 "get_issue" => reply(&work.get_issue(args(body)?).await?),
2729 "update_issue" => reply(&work.update_issue(args(body)?).await?),
2730 "close_issue" => reply(&work.close_issue(args(body)?).await?),
2731 "reopen_issue" => reply(&work.reopen_issue(args(body)?).await?),
2732 "list_labels" => reply(&work.list_labels(args(body)?).await?),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2733 "save_label" => reply(&work.save_label(args(body)?).await?),
2734 "delete_label" => reply(&work.delete_label(args(body)?).await?),
2735 "add_default_labels" => reply(&work.add_default_labels(args(body)?).await?),
2736 "set_labels" => reply(&work.set_labels(args(body)?).await?),
2737 "list_milestones" => reply(&work.list_milestones(args(body)?).await?),
2738 "get_milestone" => reply(&work.get_milestone(args(body)?).await?),
2739 "save_milestone" => reply(&work.save_milestone(args(body)?).await?),
2740 "delete_milestone" => reply(&work.delete_milestone(args(body)?).await?),
Put the stylesheet at the top of the head, before React Router's preloads2741 "counts" => reply(&work.counts(args(body)?).await?),
2742 "add_comment" => reply(&work.add_comment(args(body)?).await?),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts2743 "edit_comment" => reply(&work.edit_comment(args(body)?).await?),
2744 "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 calendar2745 // A workspace's own agents, as themselves (agent_comments.rs). For
2746 // the agents service; never reachable with a person's token.
2747 "workspace_agent_comment" => reply(&work.workspace_agent_comment(args(body)?).await?),
2748 "workspace_agent_review" => reply(&work.workspace_agent_review(args(body)?).await?),
Put the stylesheet at the top of the head, before React Router's preloads2749 "start_checks" => reply(&work.start_checks(args(body)?).await?),
2750 "seen_checks" => reply(&work.seen_checks(args(body)?).await?),
2751 "report_checks" => reply(&work.report_checks(args(body)?).await?),
2752 "set_commit_status" => reply(&work.set_commit_status(args(body)?).await?),
Merge checks: statuses and check runs on every commit2753 "create_commit_status" => reply(&work.create_commit_status(args(body)?).await?),
2754 "commit_statuses" => reply(&work.commit_statuses(args(body)?).await?),
2755 "combined_status" => reply(&work.combined_status(args(body)?).await?),
2756 "create_check_run" => reply(&work.create_check_run(args(body)?).await?),
2757 "update_check_run" => reply(&work.update_check_run(args(body)?).await?),
2758 "get_check_run" => reply(&work.get_check_run(args(body)?).await?),
2759 "check_run_annotations" => reply(&work.check_run_annotations(args(body)?).await?),
2760 "ref_check_runs" => reply(&work.ref_check_runs(args(body)?).await?),
2761 "ref_check_suites" => reply(&work.ref_check_suites(args(body)?).await?),
2762 "get_check_suite" => reply(&work.get_check_suite(args(body)?).await?),
2763 "rerequest_check_run" => reply(&work.rerequest_check_run(args(body)?).await?),
2764 "rerequest_check_suite" => reply(&work.rerequest_check_suite(args(body)?).await?),
2765 "request_check_action" => reply(&work.request_check_action(args(body)?).await?),
2766 "commit_checks" => reply(&work.commit_checks(args(body)?).await?),
Put the stylesheet at the top of the head, before React Router's preloads2767 "start_review" => reply(&work.start_review(args(body)?).await?),
2768 "advance" => reply(&work.advance(args(body)?).await?),
2769 "stall" => reply(&work.stall(args(body)?).await?),
2770 "managed_pulls" => reply(&work.managed_pulls(args(body)?).await?),
2771 "queue" => reply(&work.queue(args(body)?).await?),
2772 "queue_build" => reply(&work.queue_build(args(body)?).await?),
2773 "report_queue" => reply(&work.report_queue(args(body)?).await?),
2774 "remove_from_queue" => reply(&work.remove_from_queue(args(body)?).await?),
2775 "message_agent" => reply(&work.message_agent(args(body)?).await?),
2776 "locate_pull" => reply(&work.locate_pull(args(body)?).await?),
Inbox: the events service tells people what needs them as events arrive2777 "inbox_subject" => reply(&work.inbox_subject(args(body)?).await?),
Put the stylesheet at the top of the head, before React Router's preloads2778 "answer_message" => reply(&work.answer_message(args(body)?).await?),
2779 "take_messages" => reply(&work.take_messages(args(body)?).await?),
2780 "wake_for_messages" => reply(&work.wake_for_messages(args(body)?).await?),
2781 "catch_up_job" => reply(&work.catch_up_job(args(body)?).await?),
2782 "get_settings" => reply(&work.get_settings(args(body)?).await?),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2783 "codeowners_errors" => reply(&work.codeowners_errors(args(body)?).await?),
Put the stylesheet at the top of the head, before React Router's preloads2784 "update_settings" => reply(&work.update_settings(args(body)?).await?),
2785 "report_review" => reply(&work.report_review(args(body)?).await?),
2786 "open_pull" => reply(&work.open_pull(args(body)?).await?),
2787 "list_pulls" => reply(&work.list_pulls(args(body)?).await?),
2788 "pulls_for_repos" => reply(&work.pulls_for_repos(args(body)?).await?),
2789 "get_pull" => reply(&work.get_pull(args(body)?).await?),
2790 "update_pull" => reply(&work.update_pull(args(body)?).await?),
2791 "catch_up_pull" => reply(&work.catch_up_pull(args(body)?).await?),
2792 "ready_pull" => reply(&work.ready_pull(args(body)?).await?),
2793 "close_pull" => reply(&work.close_pull(args(body)?).await?),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts2794 "reopen_pull" => reply(&work.reopen_pull(args(body)?).await?),
2795 "convert_pull_to_draft" => reply(&work.convert_pull_to_draft(args(body)?).await?),
Put the stylesheet at the top of the head, before React Router's preloads2796 "merge_pull" => reply(&work.merge_pull(args(body)?).await?),
2797 "list_active_pulls" => reply(&work.list_active_pulls(args(body)?).await?),
2798 "by_author" => reply(&work.by_author(args(body)?).await?),
Chat controls, public profiles, shadcn selects, and no Docs tab in a project2799 "contributions" => reply(&work.contributions(args(body)?).await?),
Put the stylesheet at the top of the head, before React Router's preloads2800 "start_plan" => reply(&work.start_plan(args(body)?).await?),
2801 "report_plan" => reply(&work.report_plan(args(body)?).await?),
2802 "get_plan" => reply(&work.get_plan(args(body)?).await?),
2803 "list_plans" => reply(&work.list_plans(args(body)?).await?),
2804 "apply_plan" => reply(&work.apply_plan(args(body)?).await?),
2805 "queue_issue" => reply(&work.queue_issue(args(body)?).await?),
2806 "ready_issues" => reply(&work.ready_issues(args(body)?).await?),
2807 "list_assigned_issues" => reply(&work.list_assigned_issues(args(body)?).await?),
2808 "append_session" => reply(&work.append_session(args(body)?).await?),
2809 "read_session" => reply(&work.read_session(args(body)?).await?),
2810 // Agents at work, their sessions, and memory (runs.rs, memory.rs).
2811 "open_run" => reply(&work.open_run(args(body)?).await?),
2812 "report_run" => reply(&work.report_run(args(body)?).await?),
2813 "stop_run" => reply(&work.stop_run(args(body)?).await?),
2814 "list_runs" => reply(&work.list_runs(args(body)?).await?),
2815 "get_run" => reply(&work.get_run(args(body)?).await?),
2816 "list_sessions" => reply(&work.list_sessions(args(body)?).await?),
2817 "get_session" => reply(&work.get_session(args(body)?).await?),
2818 "list_memories" => reply(&work.list_memories(args(body)?).await?),
2819 "add_memory" => reply(&work.add_memory(args(body)?).await?),
2820 "update_memory" => reply(&work.update_memory(args(body)?).await?),
2821 "delete_memory" => reply(&work.delete_memory(args(body)?).await?),
2822 "recall" => reply(&work.recall(args(body)?).await?),
2823 "memory_context" => reply(&work.memory_context(args(body)?).await?),
2824 // What agents may do in a sandbox (guardrails.rs).
2825 "get_guardrails" => reply(&work.get_guardrails(args(body)?).await?),
2826 "update_guardrails" => reply(&work.update_guardrails(args(body)?).await?),
2827 "run_guardrails" => reply(&work.run_guardrails(args(body)?).await?),
2828 // Plan caps the runner applies (compute.rs).
2829 "active_agents" => reply(&work.active_agents(args(body)?).await?),
2830 "issue_spend" => reply(&work.issue_spend(args(body)?).await?),
2831 "wait_for_slot" => reply(&work.wait_for_slot(args(body)?).await?),
2832 "agent_comment" => reply(&work.agent_comment(args(body)?).await?),
2833 "add_wait" => reply(&work.add_wait(args(body)?).await?),
2834 "waiting_workspaces" => reply(&work.waiting_workspaces(args(body)?).await?),
2835 "take_wait" => reply(&work.take_wait(args(body)?).await?),
2836 // The runs whose sandboxes stop with their repository (retired.rs).
2837 "runs_in_repo" => reply(&work.runs_in_repo(args(body)?).await?),
2838 "run_cost" => reply(&work.run_cost(args(body)?).await?),
2839 "start_mergecheck" => reply(&work.start_mergecheck(args(body)?).await?),
2840 "report_mergecheck" => reply(&work.report_mergecheck(args(body)?).await?),
2841 // Memory that fills itself, and its review queue (capture.rs).
2842 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-agent2843 // @g1t in comments, and the label rule (mentions.rs).
Put the stylesheet at the top of the head, before React Router's preloads2844 "take_mention" => reply(&work.take_mention(args(body)?).await?),
2845 "mention_revision" => reply(&work.mention_revision(args(body)?).await?),
2846 "reply_mention" => reply(&work.reply_mention(args(body)?).await?),
2847 "get_agent_rules" => reply(&work.get_agent_rules(args(body)?).await?),
2848 "set_agent_rules" => reply(&work.set_agent_rules(args(body)?).await?),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge2849 // Rulesets (rulesets.rs): kept here, enforced here on merge and by
2850 // repos on push.
2851 "list_rulesets" => reply(&work.list_rulesets(args(body)?).await?),
2852 "get_ruleset" => reply(&work.get_ruleset(args(body)?).await?),
2853 "save_ruleset" => reply(&work.save_ruleset(args(body)?).await?),
2854 "delete_ruleset" => reply(&work.delete_ruleset(args(body)?).await?),
2855 "effective_rules" => reply(&work.effective_rules(args(body)?).await?),
2856 "rule_evaluations" => reply(&work.rule_evaluations(args(body)?).await?),
2857 "ref_rules" => reply(&work.ref_rules(args(body)?).await?),
2858 "record_evaluations" => reply(&work.record_evaluations(args(body)?).await?),
2859 "set_requires_pull_request" => reply(&work.set_requires_pull_request(args(body)?).await?),
Put the stylesheet at the top of the head, before React Router's preloads2860 _ => Response::error("Unknown method", 404),
2861 };
2862 served.finish_timed(answered, &work.timing)
2863}
2864
2865/// Events from the bus, delivered on this service's own queue.
2866#[event(queue)]
2867async fn queue(batch: MessageBatch<Event>, env: Env, _ctx: Context) -> Result<()> {
2868 let work = service(&env)?;
2869 for message in batch.messages()? {
2870 // 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 merge2871 if g1t_kit::rename::on_event(&env, &env.d1("DB")?, message.body(), &[memory::RENAMED, guardrails::RENAMED, rulesets::RENAMED].concat()).await? {
Put the stylesheet at the top of the head, before React Router's preloads2872 message.ack();
2873 continue;
2874 }
2875 // A repository renamed or transferred: its runs, memory, guardrails
2876 // and runs waiting for a slot follow.
2877 if g1t_kit::transfer::on_event(&env, &env.d1("DB")?, message.body(), &[memory::TRANSFERRED, guardrails::TRANSFERRED, retired::WAITS_MOVED].concat()).await? {
2878 message.ack();
2879 continue;
2880 }
2881 // A workspace deleted: what it kept for itself goes.
2882 if g1t_kit::deleted::on_event(&env.d1("DB")?, message.body(), memory::DELETED).await? {
2883 message.ack();
2884 continue;
2885 }
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)2886 // An account purged: what it wrote shows as ghost (ghost.rs).
2887 let ghost = ghost::statements();
2888 let ghost: Vec<&str> = ghost.iter().map(String::as_str).collect();
2889 if g1t_kit::user_deleted::on_event(&env.d1("DB")?, message.body(), &ghost).await? {
2890 message.ack();
2891 continue;
2892 }
Put the stylesheet at the top of the head, before React Router's preloads2893 // A repository deleted, archived or purged, or a branch renamed (retired.rs).
2894 if work.on_retired(message.body()).await? {
2895 message.ack();
2896 continue;
2897 }
2898 capture::on_event(&work, message.body()).await;
2899 work.on_event(message.body()).await?;
2900 message.ack();
2901 }
2902 Ok(())
2903}
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights2904
2905/// The rules that once read a pull request's author read its owner now:
2906/// whoever asked g1t for it, or its author. For each, the person who asked
2907/// is held to what an author was, and g1t's agent (a token it works with)
2908/// gains nothing by being the author.
2909#[cfg(test)]
2910mod owner_rules {
2911 use super::*;
2912 use crate::rows::stored::{ASKER, G1T, pull};
2913 use g1t_contracts::identity::AGENT_ID;
2914
2915 const SOMEONE: &str = "usr_2";
2916
2917 #[test]
2918 fn no_self_approval() {
2919 // add_comment refuses a verdict on one that is theirs.
2920 let made = pull(G1T, Some(ASKER));
2921 assert!(made.is_owned_by(ASKER.0), "the person who asked cannot approve it");
2922 assert!(!made.is_owned_by(AGENT_ID), "g1t's review agent still gives its verdict");
2923 assert!(!made.is_owned_by(SOMEONE));
2924 }
2925
2926 #[test]
2927 fn what_an_author_could_do_without_a_role() {
2928 // manageable_pull (update, ready, close), catch_up_pull on a fork,
2929 // append_session, and steering with message_agent: theirs to do.
2930 let made = pull(G1T, Some(ASKER));
2931 assert!(made.is_owned_by(ASKER.0));
2932 assert!(!made.is_owned_by(SOMEONE), "anyone else still needs the role");
2933 assert!(!made.is_owned_by(AGENT_ID), "being its author gives g1t's tokens nothing more");
2934 }
2935
2936 #[test]
2937 fn nobody_is_asked_to_review_what_they_asked_for() {
2938 // update_pull drops the owner from the reviewers asked.
2939 let made = pull(G1T, Some(ASKER));
2940 let mut reviewers = vec!["syntaqx".to_owned(), "ana".to_owned()];
2941 reviewers.retain(|name| *name != made.owner().username);
2942 assert_eq!(reviewers, ["ana"]);
2943 }
2944
2945 #[test]
2946 fn sandboxes_act_as_whoever_asked() {
2947 // LifecycleJob, ReviewJob, MergecheckJob and the merge queue's job
2948 // carry who the sandbox's credential acts for: a real account.
2949 let made = pull(G1T, Some(ASKER));
2950 assert_eq!(made.owner().id, ASKER.0);
2951 let acts_as = made.requested_by.unwrap_or(made.author);
2952 assert_eq!(acts_as.id, ASKER.0);
2953 // g1t's own work, which nobody asked for, acts as g1t, as before.
2954 let own = pull(("g1t", "g1t"), None);
2955 assert_eq!(own.requested_by.unwrap_or(own.author).id, "g1t");
2956 }
2957
2958 #[test]
2959 fn events_name_g1t_and_whoever_asked() {
2960 let made = pull(G1T, Some(ASKER));
2961 let event = serde_json::to_value(Work::pull_event(&made)).unwrap();
2962 assert_eq!(event["author"], serde_json::json!({ "id": AGENT_ID, "username": "g1t" }));
2963 assert_eq!(event["requestedBy"], serde_json::json!({ "id": "usr_1", "username": "syntaqx" }));
2964 let own = serde_json::to_value(Work::pull_event(&pull(ASKER, None))).unwrap();
2965 assert!(own.get("requestedBy").is_none());
2966 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts2967
2968 #[test]
2969 fn only_a_closed_pull_request_reopens_and_only_an_open_one_turns_draft() {
2970 assert_eq!(reopen_refusal(PullStatus::Closed), None);
2971 assert!(reopen_refusal(PullStatus::Merged).is_some(), "a merge cannot be undone");
2972 assert!(reopen_refusal(PullStatus::Open).is_some());
2973 assert!(reopen_refusal(PullStatus::Draft).is_some());
2974 assert_eq!(draft_refusal(PullStatus::Open), None);
2975 assert!(draft_refusal(PullStatus::Draft).is_some());
2976 assert!(draft_refusal(PullStatus::Closed).is_some());
2977 assert!(draft_refusal(PullStatus::Merged).is_some());
2978 }
2979
2980 #[test]
2981 fn a_pull_request_reopens_as_what_it_was_closed_as() {
2982 assert_eq!(reopened_status(Some("draft")), PullStatus::Draft);
2983 assert_eq!(reopened_status(Some("open")), PullStatus::Open);
2984 // Closed before this was recorded, or by a merge of another.
2985 assert_eq!(reopened_status(None), PullStatus::Open);
2986 }
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights2987}

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