Skip to content

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

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