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.

2,867 linesCodeBlameRaw
1//! The work service: issues, pull requests, comments and sessions.
2//!
3//! Each `*Args` struct is the argument of the method of the same name,
4//! served at `POST /rpc/<method>`.
5//!
6//! Issues and pull requests share one sequence of numbers per repository,
7//! so `#12` names exactly one of them.
8
9use serde::{Deserialize, Serialize};
10
11use crate::repos::{CompareArgs, RepoPath, UpdateMethod};
12use crate::rules::MergeMethod;
13use crate::{User, Viewer};
14
15// Sessions, claims and dependencies: work/coordination.rs.
16mod insights;
17pub use insights::*;
18
19mod coordination;
20pub use coordination::*;
21
22/// The labels a new repository starts with, and that "Add the default
23/// labels" adds to one that is missing some: `(name, color, description)`.
24/// Colors are six hex digits, without `#`.
25pub const DEFAULT_LABELS: [(&str, &str, &str); 11] = [
26 ("bug", "d73a4a", "Something isn't working"),
27 ("documentation", "0075ca", "Improvements or additions to documentation"),
28 ("duplicate", "cfd3d7", "This issue or pull request already exists"),
29 ("enhancement", "a2eeef", "New feature or request"),
30 ("good first issue", "7057ff", "Good for newcomers"),
31 ("help wanted", "008672", "Extra attention is needed"),
32 ("invalid", "e4e669", "This doesn't seem right"),
33 ("question", "d876e3", "Further information is requested"),
34 ("wontfix", "ffffff", "This will not be worked on"),
35 ("dependencies", "0366d6", "Updates a dependency"),
36 ("security", "ee0701", "A security fix or a vulnerability"),
37];
38
39/// The color a label gets when none is given: chosen from its name, so
40/// the same name always gets the same color.
41pub fn label_color_for(name: &str) -> String {
42 const PALETTE: [&str; 12] = [
43 "b60205", "d93f0b", "fbca04", "0e8a16", "006b75", "1d76db", "0052cc", "5319e7", "e99695", "f9d0c4",
44 "c2e0c6", "bfdadc",
45 ];
46 if let Some((_, color, _)) = DEFAULT_LABELS.iter().find(|(known, _, _)| *known == name) {
47 return (*color).to_owned();
48 }
49 let hash = name.bytes().fold(0u32, |hash, byte| hash.wrapping_mul(31).wrapping_add(u32::from(byte)));
50 PALETTE[(hash as usize) % PALETTE.len()].to_owned()
51}
52
53/// A color as six lowercase hex digits, from `#A1B2C3`, `a1b2c3` or `abc`.
54pub fn tidy_color(color: &str) -> Option<String> {
55 let hex = color.trim().trim_start_matches('#').to_ascii_lowercase();
56 if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
57 return None;
58 }
59 match hex.len() {
60 6 => Some(hex),
61 3 => Some(hex.chars().flat_map(|c| [c, c]).collect()),
62 _ => None,
63 }
64}
65
66/// The most labels one issue or pull request can carry, and the longest
67/// name and description of one.
68pub const MAX_LABELS: usize = 20;
69pub const MAX_LABEL_CHARS: usize = 50;
70pub const MAX_LABEL_DESCRIPTION_CHARS: usize = 100;
71
72/// A label of a repository: a name, a color and what it means. Issues and
73/// pull requests carry labels by name. Names are lowercase and unique in
74/// a repository.
75#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct Label {
78 pub name: String,
79 /// Six hex digits, without `#`.
80 pub color: String,
81 #[serde(default)]
82 pub description: String,
83 /// How many issues carry it, open or closed.
84 #[serde(default)]
85 pub issues: u32,
86 /// How many pull requests carry it, in any state.
87 #[serde(default)]
88 pub pulls: u32,
89}
90
91/// A milestone, as an issue or pull request names it.
92#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase")]
94pub struct MilestoneRef {
95 pub number: u32,
96 pub title: String,
97}
98
99/// A milestone: a goal, with an optional due date, that issues and pull
100/// requests are gathered under. Its progress is how many of them are
101/// closed (a merged pull request counts as closed).
102#[derive(Clone, Debug, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct Milestone {
105 /// Numbered from 1 in each repository, apart from issues.
106 pub number: u32,
107 pub title: String,
108 /// Markdown.
109 #[serde(default)]
110 pub description: String,
111 /// The day it is due, `YYYY-MM-DD`.
112 #[serde(default)]
113 pub due_on: Option<String>,
114 pub state: State,
115 /// Open issues and pull requests in it (drafts count as open).
116 #[serde(default)]
117 pub open_items: u32,
118 /// Closed issues, and merged or closed pull requests, in it.
119 #[serde(default)]
120 pub closed_items: u32,
121 /// RFC 3339.
122 pub created_at: String,
123 /// RFC 3339.
124 pub updated_at: String,
125 /// RFC 3339.
126 #[serde(default)]
127 pub closed_at: Option<String>,
128}
129
130impl Milestone {
131 /// How far along it is, from 0 to 100: closed items over all of them.
132 pub fn percent(&self) -> u32 {
133 let total = self.open_items + self.closed_items;
134 (self.closed_items * 100).checked_div(total).unwrap_or(0)
135 }
136}
137
138/// The most characters a milestone's title and description may have.
139pub const MAX_MILESTONE_TITLE_CHARS: usize = 100;
140pub const MAX_MILESTONE_DESCRIPTION_CHARS: usize = 4000;
141
142/// A due date as `YYYY-MM-DD`, from that or from an RFC 3339 time.
143pub fn tidy_due_on(value: &str) -> Option<String> {
144 let day = value.trim().get(..10)?;
145 let bytes = day.as_bytes();
146 let digits = |range: std::ops::Range<usize>| bytes[range].iter().all(u8::is_ascii_digit);
147 if !(digits(0..4) && bytes[4] == b'-' && digits(5..7) && bytes[7] == b'-' && digits(8..10)) {
148 return None;
149 }
150 let month: u32 = day[5..7].parse().ok()?;
151 let date: u32 = day[8..10].parse().ok()?;
152 ((1..=12).contains(&month) && (1..=31).contains(&date)).then(|| day.to_owned())
153}
154
155/// `list_labels`: a repository's labels, by name, each with how many
156/// issues and pull requests carry it. Takes `ViewArgs`; returns
157/// `Outcome<Vec<Label>>`.
158///
159/// `save_label`: creates a label, or with `name` changes one (renaming it
160/// renames it on every issue and pull request). Needs the Triage role.
161/// Returns `Outcome<Label>`.
162#[derive(Debug, Serialize, Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub struct SaveLabelArgs {
165 pub actor: User,
166 pub repo: RepoPath,
167 /// The label to change; absent to create one.
168 #[serde(default)]
169 pub name: Option<String>,
170 /// The name it should have: required to create one.
171 #[serde(default)]
172 pub new_name: Option<String>,
173 /// Six hex digits; one is chosen from the name when creating without.
174 #[serde(default)]
175 pub color: Option<String>,
176 #[serde(default)]
177 pub description: Option<String>,
178}
179
180/// `delete_label`: removes a label from the repository and from every
181/// issue and pull request that carries it. Needs the Triage role. Returns
182/// `Outcome<bool>`.
183#[derive(Debug, Serialize, Deserialize)]
184pub struct DeleteLabelArgs {
185 pub actor: User,
186 pub repo: RepoPath,
187 pub name: String,
188}
189
190/// `add_default_labels`: adds those of [`DEFAULT_LABELS`] the repository
191/// does not have yet. Needs the Triage role. Returns
192/// `Outcome<Vec<Label>>`, every label it has now.
193#[derive(Debug, Serialize, Deserialize)]
194pub struct RepoActorArgs {
195 pub actor: User,
196 pub repo: RepoPath,
197}
198
199/// How `set_labels` changes an issue's or pull request's labels.
200#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "lowercase")]
202pub enum LabelChange {
203 /// Replace them all.
204 #[default]
205 Set,
206 /// Add these to the ones it has.
207 Add,
208 /// Take these off.
209 Remove,
210}
211
212/// `set_labels`: changes the labels of an issue or a pull request (they
213/// share numbers). A label the repository does not have yet is created
214/// when the actor has the Triage role; anyone else may only use existing
215/// ones, on what they opened. Returns `Outcome<Vec<String>>`, its labels
216/// now.
217#[derive(Debug, Serialize, Deserialize)]
218#[serde(rename_all = "camelCase")]
219pub struct SetLabelsArgs {
220 pub actor: User,
221 pub repo: RepoPath,
222 pub number: u32,
223 pub labels: Vec<String>,
224 #[serde(default)]
225 pub change: LabelChange,
226}
227
228/// `list_milestones`: a repository's milestones, open ones by due date
229/// (soonest first, those without one last), then closed ones most recently
230/// closed first. Returns `Outcome<Vec<Milestone>>`.
231#[derive(Debug, Serialize, Deserialize)]
232pub struct ListMilestonesArgs {
233 pub repo: RepoPath,
234 pub viewer: Viewer,
235 /// Both when absent.
236 #[serde(default)]
237 pub state: Option<State>,
238}
239
240/// `get_milestone`: one milestone and what is in it. Takes `ViewArgs`,
241/// whose `number` is the milestone's. Returns `Outcome<MilestoneDetail>`.
242#[derive(Clone, Debug, Serialize, Deserialize)]
243pub struct MilestoneDetail {
244 pub milestone: Milestone,
245 /// Newest first, open and closed.
246 pub issues: Vec<Issue>,
247 /// Newest first, in any state.
248 pub pulls: Vec<Pull>,
249}
250
251/// `save_milestone`: creates a milestone, or with `number` changes the
252/// fields given. Needs the Triage role. Returns `Outcome<Milestone>`.
253#[derive(Debug, Serialize, Deserialize)]
254#[serde(rename_all = "camelCase")]
255pub struct SaveMilestoneArgs {
256 pub actor: User,
257 pub repo: RepoPath,
258 /// The milestone to change; absent to create one.
259 #[serde(default)]
260 pub number: Option<u32>,
261 /// Required to create one.
262 #[serde(default)]
263 pub title: Option<String>,
264 #[serde(default)]
265 pub description: Option<String>,
266 /// `YYYY-MM-DD`; an empty string clears it.
267 #[serde(default)]
268 pub due_on: Option<String>,
269 #[serde(default)]
270 pub state: Option<State>,
271}
272
273/// `delete_milestone`: removes a milestone; what was in it is in none.
274/// Needs the Triage role. Returns `Outcome<bool>`.
275#[derive(Debug, Serialize, Deserialize)]
276pub struct DeleteMilestoneArgs {
277 pub actor: User,
278 pub repo: RepoPath,
279 pub number: u32,
280}
281
282/// `open` or `closed`: the filter on lists of issues and pull requests. An
283/// open pull request is a draft or one ready for review; a closed one was
284/// merged or closed without merging.
285#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
286#[serde(rename_all = "lowercase")]
287pub enum State {
288 Open,
289 Closed,
290}
291
292/// Why an issue was closed.
293#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
294#[serde(rename_all = "snake_case")]
295pub enum IssueReason {
296 /// The work was done. If a pull request did it, `resolved_by` names it.
297 Completed,
298 NotPlanned,
299}
300
301impl IssueReason {
302 pub fn as_str(self) -> &'static str {
303 match self {
304 IssueReason::Completed => "completed",
305 IssueReason::NotPlanned => "not_planned",
306 }
307 }
308}
309
310/// Something that should change in a repository: a bug, a feature, a
311/// question. Opened by a person, an agent or an integration. Pull requests
312/// are made against it; the one that is merged resolves it.
313#[derive(Clone, Debug, Serialize, Deserialize)]
314#[serde(rename_all = "camelCase")]
315pub struct Issue {
316 pub id: String,
317 pub repo_id: String,
318 /// Shown as `#12`.
319 pub number: u32,
320 pub title: String,
321 /// Markdown. Also what an agent is given to work from.
322 pub body: String,
323 pub labels: Vec<String>,
324 pub state: State,
325 /// Set when closed.
326 pub reason: Option<IssueReason>,
327 /// The number of the pull request whose merge closed this issue.
328 pub resolved_by: Option<u32>,
329 /// Who opened it: a person, an integration, or g1t (`kind` `agent`)
330 /// for one its agent filed while at work.
331 pub author: User,
332 /// For an issue g1t's agent filed: the person it was working for. They
333 /// may manage it as its author could. See [`owner`](Issue::owner).
334 #[serde(default)]
335 pub requested_by: Option<User>,
336 /// RFC 3339.
337 pub created_at: String,
338 /// RFC 3339.
339 pub updated_at: String,
340 /// RFC 3339.
341 pub closed_at: Option<String>,
342 /// Pull requests made against this issue, in any state.
343 pub pull_count: u32,
344 pub comment_count: u32,
345 /// Usernames of the people it is assigned to.
346 #[serde(default)]
347 pub assignees: Vec<String>,
348 /// The numbers of the issues that have to be merged before this one is
349 /// worked on.
350 #[serde(default)]
351 pub blocked_by: Vec<u32>,
352 /// The agent working on it now: the one behind its newest pull request
353 /// that is still in progress in a fork, such as `g1t`.
354 #[serde(default)]
355 pub agent: Option<String>,
356 /// The milestone it is in, if any.
357 #[serde(default)]
358 pub milestone: Option<MilestoneRef>,
359}
360
361#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
362#[serde(rename_all = "lowercase")]
363pub enum PullStatus {
364 /// Still being worked on.
365 Draft,
366 /// Ready for review.
367 Open,
368 Merged,
369 /// Closed without merging.
370 Closed,
371}
372
373impl PullStatus {
374 pub fn as_str(self) -> &'static str {
375 match self {
376 PullStatus::Draft => "draft",
377 PullStatus::Open => "open",
378 PullStatus::Merged => "merged",
379 PullStatus::Closed => "closed",
380 }
381 }
382
383 /// Whether the pull request can still be changed or merged.
384 pub fn is_active(self) -> bool {
385 matches!(self, PullStatus::Draft | PullStatus::Open)
386 }
387}
388
389/// Where the agent runs: on g1t's sandboxes, or in someone's own session.
390#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
391#[serde(rename_all = "lowercase")]
392pub enum Runtime {
393 Hosted,
394 External,
395}
396
397/// A proposed change. It is made either in a fork created for it, which is
398/// how agents work, on a branch pushed to the repository itself, or on a
399/// branch of someone's fork of it (`head_repo`).
400#[derive(Clone, Debug, Serialize, Deserialize)]
401#[serde(rename_all = "camelCase")]
402pub struct Pull {
403 pub id: String,
404 pub repo_id: String,
405 /// Shown as `#12`.
406 pub number: u32,
407 /// The number of the issue this is for, if any.
408 pub issue: Option<u32>,
409 pub title: String,
410 /// Markdown: what changed and why. Set when marked ready.
411 pub body: Option<String>,
412 /// A label for the agent doing the work, e.g. `claude-code`.
413 pub agent: String,
414 pub runtime: Runtime,
415 pub status: PullStatus,
416 /// The fork holding the change, unless it is on a branch.
417 pub fork: Option<RepoPath>,
418 /// The fork's repository id.
419 pub fork_repo_id: Option<String>,
420 /// The branch holding the change, unless it is in a working copy
421 /// (`fork`): of `head_repo` when set, else of the repository.
422 pub branch: Option<String>,
423 /// A fork of the repository (`Repo::parent_id`, directly or through
424 /// other forks) whose `branch` holds the change, when it was opened
425 /// from one. Unset for a branch of the repository itself and for a
426 /// working copy (`fork`).
427 #[serde(default)]
428 pub head_repo: Option<RepoPath>,
429 /// `head_repo`'s id.
430 #[serde(default)]
431 pub head_repo_id: Option<String>,
432 pub head_commit: Option<String>,
433 /// For a merged pull request, what the branch pointed to before the
434 /// merge. Comparing against it shows what the pull request changed.
435 pub merge_base: Option<String>,
436 /// Username of whoever merged it.
437 pub merged_by: Option<String>,
438 /// RFC 3339.
439 pub merged_at: Option<String>,
440 /// How it merged, once merged.
441 #[serde(default)]
442 pub merge_method: Option<MergeMethod>,
443 /// The commit its merge left the base at: the merge or squash commit,
444 /// the last rebased one, or its head for a fast-forward.
445 #[serde(default)]
446 pub merge_commit: Option<String>,
447 /// Merge it on its own once everything it needs is met, as a person
448 /// asked: see [`AutoMerge`].
449 #[serde(default)]
450 pub auto_merge: Option<AutoMerge>,
451 /// The issues it closes when it merges into the default branch: `issue`
452 /// and those its title and description name after a closing keyword
453 /// (`Fixes #12`, or `closes owner/repo#3` naming this repository), in
454 /// the order named. Empty where the repository does not close issues.
455 #[serde(default)]
456 pub closes: Vec<u32>,
457 /// Its branch was deleted after it merged or closed, and can be
458 /// restored at `head_commit` (`restore_head_branch`).
459 #[serde(default)]
460 pub head_branch_deleted: bool,
461 /// Set on a pull request closed because another one for the same issue
462 /// was merged: that one's number.
463 pub superseded_by: Option<u32>,
464 /// `failed` when the merge queue took it out because its combined
465 /// state failed, until its head moves. Its checks are the statuses
466 /// workflows report on its head: see `PullDetail::statuses` and
467 /// `PullDetail::required_checks`.
468 pub check_status: Option<CheckStatus>,
469 /// The files it changes, as of its latest push.
470 #[serde(default)]
471 pub files: Vec<ChangedFile>,
472 /// Usernames of the people it is assigned to.
473 #[serde(default)]
474 pub assignees: Vec<String>,
475 /// Those whose review was asked for: usernames, and `g1t` when a
476 /// g1t agent was asked.
477 #[serde(default)]
478 pub reviewers: Vec<String>,
479 /// Teams whose review was asked for, as `workspace/slug`. A team stays
480 /// here after review assignment picks people from it, who are listed
481 /// in `reviewers`.
482 #[serde(default)]
483 pub team_reviewers: Vec<String>,
484 /// The labels it carries, by name.
485 #[serde(default)]
486 pub labels: Vec<String>,
487 /// The milestone it is in, if any.
488 #[serde(default)]
489 pub milestone: Option<MilestoneRef>,
490 /// The branch it merges into. Stored as null for the repository's
491 /// default branch, so that it follows a change of default; lists and
492 /// `get_pull` fill in the name. See [`base_branch`](Pull::base_branch).
493 #[serde(default)]
494 pub base: Option<String>,
495 /// Who opened it: a person, or g1t (`kind` `agent`, username `g1t`)
496 /// for a change g1t made.
497 pub author: User,
498 /// For a change g1t made: the person who asked for it, by assigning
499 /// an issue or handing g1t the work. They answer for it as its author
500 /// would. See [`owner`](Pull::owner).
501 #[serde(default)]
502 pub requested_by: Option<User>,
503 /// RFC 3339.
504 pub created_at: String,
505 /// RFC 3339.
506 pub updated_at: String,
507 /// Who last moved its head (a user id), and when, RFC 3339: for rules
508 /// about the most recent push. Absent until a push after rulesets
509 /// arrived.
510 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub head_pushed_by: Option<String>,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
513 pub head_pushed_at: Option<String>,
514}
515
516/// One file a pull request changes, and by how much.
517#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
518pub struct ChangedFile {
519 pub path: String,
520 pub additions: u32,
521 pub deletions: u32,
522}
523
524/// Another pull request in progress that changes some of the same files.
525/// Two for the same issue are alternatives; two for different issues are
526/// heading for a conflict.
527#[derive(Clone, Debug, Serialize, Deserialize)]
528pub struct Overlap {
529 pub number: u32,
530 pub title: String,
531 /// The number of the issue the other pull request is for.
532 pub issue: Option<u32>,
533 /// The files both change.
534 pub paths: Vec<String>,
535}
536
537impl Issue {
538 /// Who the issue is theirs to manage: the person g1t's agent filed it
539 /// for, or its author. They may edit, close and reopen it without the
540 /// Triage role.
541 pub fn owner(&self) -> &User {
542 self.requested_by.as_ref().unwrap_or(&self.author)
543 }
544}
545
546impl Pull {
547 /// Who the pull request is theirs to answer for: whoever asked g1t to
548 /// make it, or its author. Every rule that once read "its author" reads
549 /// this: they may update, close and steer it, are never asked to review
550 /// it and cannot approve it, see it as theirs, and a sandbox at work on
551 /// it acts as them.
552 pub fn owner(&self) -> &User {
553 self.requested_by.as_ref().unwrap_or(&self.author)
554 }
555
556 /// Whether `id` is its [`owner`](Pull::owner)'s.
557 pub fn is_owned_by(&self, id: &str) -> bool {
558 self.owner().id == id
559 }
560
561 /// The branch it merges into: its own base, or the repository's
562 /// default branch when it has none.
563 pub fn base_branch<'a>(&'a self, default_branch: &'a str) -> &'a str {
564 self.base.as_deref().filter(|base| !base.is_empty()).unwrap_or(default_branch)
565 }
566
567 /// Whether it merges into the repository's default branch.
568 pub fn targets_default(&self, default_branch: &str) -> bool {
569 self.base_branch(default_branch) == default_branch
570 }
571
572 /// The id of the repository holding the change: its working copy, the
573 /// fork its branch is in, or the repository itself.
574 pub fn source_id(&self) -> &str {
575 self.fork_repo_id
576 .as_deref()
577 .or(self.head_repo_id.as_deref())
578 .unwrap_or(&self.repo_id)
579 }
580
581 /// The repository holding the change, by path: its working copy, the
582 /// fork its branch is in, or `repo` (the path of the repository it is
583 /// in).
584 pub fn source_path(&self, repo: &RepoPath) -> RepoPath {
585 self.fork
586 .clone()
587 .or_else(|| self.head_repo.clone())
588 .unwrap_or_else(|| repo.clone())
589 }
590
591 /// Whether the change is in another repository than the one it merges
592 /// into: a working copy or someone's fork.
593 pub fn from_other_repo(&self) -> bool {
594 self.source_id() != self.repo_id
595 }
596
597 /// Whether it was opened from a branch of someone's fork.
598 pub fn from_head_repo(&self) -> bool {
599 self.fork_repo_id.is_none() && self.head_repo_id.is_some()
600 }
601
602 /// For repos operations given its source (`target_id`): the repository
603 /// it merges into, set only for a branch of a fork, since a working copy
604 /// and a branch of the repository know their target themselves.
605 pub fn target_id(&self) -> Option<String> {
606 self.from_head_repo().then(|| self.repo_id.clone())
607 }
608
609 /// What to ask the repos service to see what this pull request changes.
610 ///
611 /// A working copy is compared as a whole. A branch, of the repository or
612 /// of a fork, is compared by name while the pull request is open, and by
613 /// the commit it was merged or closed at afterwards, so later pushes to
614 /// the branch do not change the record.
615 pub fn comparison(&self, viewer: &Viewer) -> CompareArgs {
616 let settled = matches!(self.status, PullStatus::Merged | PullStatus::Closed);
617 let head = match &self.fork_repo_id {
618 Some(_) => None,
619 // A fork's branch, by the commit it was last seen at: one the
620 // repository it merges into holds too (`refs/pull/<n>/head`),
621 // so those who read that repository can read it.
622 None if self.from_head_repo() => self.head_commit.clone().or_else(|| self.branch.clone()),
623 None => self
624 .head_commit
625 .clone()
626 .filter(|_| settled)
627 .or_else(|| self.branch.clone()),
628 };
629 CompareArgs {
630 repo_id: self.source_id().to_owned(),
631 viewer: viewer.clone(),
632 base: self.merge_base.clone(),
633 head,
634 base_branch: self.base.clone().filter(|base| !base.is_empty()),
635 paths: None,
636 target_id: self.target_id(),
637 }
638 }
639}
640
641/// g1t's agent, as the author of what it opens: stored by
642/// [`AGENT_ID`](crate::identity::AGENT_ID), shown as `g1t`.
643pub fn g1t_author() -> User {
644 User {
645 id: crate::identity::AGENT_ID.to_owned(),
646 username: crate::identity::AGENT_NAME.to_owned(),
647 kind: crate::PrincipalKind::Agent,
648 ..User::default()
649 }
650}
651
652/// Who is recorded as opening an issue or a pull request that `actor`
653/// opens, and who asked for it: `(author, requested_by)`.
654///
655/// - g1t's retired agent, acting for someone through a token of its own
656/// that still works: g1t, requested by that person.
657/// - Anyone else, and g1t's own work that nobody asked for: the actor, and
658/// nobody asking.
659pub fn authorship(actor: &User) -> (User, Option<User>) {
660 let person = |id: &str, username: &str| User {
661 id: id.to_owned(),
662 username: username.to_owned(),
663 kind: crate::PrincipalKind::User,
664 ..User::default()
665 };
666 if actor.kind == crate::PrincipalKind::Agent {
667 let asked = actor
668 .acting
669 .as_ref()
670 .map(|acting| &acting.on_behalf_of)
671 .filter(|on_behalf_of| !crate::system::is_system_id(&on_behalf_of.id))
672 .map(|on_behalf_of| person(&on_behalf_of.id, &on_behalf_of.username));
673 return (g1t_author(), asked);
674 }
675 let author = User {
676 id: actor.id.clone(),
677 username: actor.username.clone(),
678 kind: actor.kind,
679 ..User::default()
680 };
681 (author, None)
682}
683
684#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
685#[serde(rename_all = "lowercase")]
686pub enum CheckStatus {
687 /// Waiting for a sandbox.
688 Queued,
689 Running,
690 Passed,
691 Failed,
692 /// The checks could not be run at all.
693 Errored,
694}
695
696impl CheckStatus {
697 pub fn as_str(self) -> &'static str {
698 match self {
699 CheckStatus::Queued => "queued",
700 CheckStatus::Running => "running",
701 CheckStatus::Passed => "passed",
702 CheckStatus::Failed => "failed",
703 CheckStatus::Errored => "errored",
704 }
705 }
706}
707
708/// How one command went. Recorded by earlier runs of commands written on
709/// issues, which g1t no longer runs; kept so their history still reads.
710#[derive(Clone, Debug, Serialize, Deserialize)]
711#[serde(rename_all = "camelCase")]
712pub struct CheckResult {
713 pub command: String,
714 pub passed: bool,
715 /// Absent when the command was stopped for taking too long.
716 #[serde(default)]
717 pub exit_code: Option<i32>,
718 /// What the command printed, standard output and error together. The
719 /// end of it, when there was a lot.
720 #[serde(default)]
721 pub output: String,
722 #[serde(default)]
723 pub duration_ms: u64,
724}
725
726/// A record against a pull request's head: the merge queue taking it out,
727/// with why, or an earlier run of commands written on its issue.
728#[derive(Clone, Debug, Serialize, Deserialize)]
729#[serde(rename_all = "camelCase")]
730pub struct CheckRun {
731 pub id: String,
732 /// The commit that was checked.
733 pub head_commit: String,
734 pub status: CheckStatus,
735 pub results: Vec<CheckResult>,
736 /// Why the checks could not be run, when `status` is `errored`.
737 pub error: Option<String>,
738 /// RFC 3339.
739 pub created_at: String,
740 /// RFC 3339.
741 pub finished_at: Option<String>,
742}
743
744/// A reviewer's decision on a pull request.
745#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
746#[serde(rename_all = "snake_case")]
747pub enum Verdict {
748 Approve,
749 RequestChanges,
750}
751
752impl Verdict {
753 pub fn as_str(self) -> &'static str {
754 match self {
755 Verdict::Approve => "approve",
756 Verdict::RequestChanges => "request_changes",
757 }
758 }
759}
760
761/// An emoji reaction to an issue, a pull request or a comment: the same
762/// eight as everywhere else people react to code.
763#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
764pub enum ReactionContent {
765 #[serde(rename = "+1")]
766 PlusOne,
767 #[serde(rename = "-1")]
768 MinusOne,
769 #[serde(rename = "laugh")]
770 Laugh,
771 #[serde(rename = "hooray")]
772 Hooray,
773 #[serde(rename = "confused")]
774 Confused,
775 #[serde(rename = "heart")]
776 Heart,
777 #[serde(rename = "rocket")]
778 Rocket,
779 /// g1t's: it has seen what it was asked and is on it.
780 #[serde(rename = "eyes")]
781 Eyes,
782}
783
784impl ReactionContent {
785 pub const ALL: [ReactionContent; 8] = [
786 ReactionContent::PlusOne,
787 ReactionContent::MinusOne,
788 ReactionContent::Laugh,
789 ReactionContent::Hooray,
790 ReactionContent::Confused,
791 ReactionContent::Heart,
792 ReactionContent::Rocket,
793 ReactionContent::Eyes,
794 ];
795
796 pub fn as_str(self) -> &'static str {
797 match self {
798 ReactionContent::PlusOne => "+1",
799 ReactionContent::MinusOne => "-1",
800 ReactionContent::Laugh => "laugh",
801 ReactionContent::Hooray => "hooray",
802 ReactionContent::Confused => "confused",
803 ReactionContent::Heart => "heart",
804 ReactionContent::Rocket => "rocket",
805 ReactionContent::Eyes => "eyes",
806 }
807 }
808
809 pub fn parse(name: &str) -> Option<ReactionContent> {
810 ReactionContent::ALL.into_iter().find(|content| content.as_str() == name.trim())
811 }
812}
813
814/// Everyone who reacted one way to the same thing.
815#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
816#[serde(rename_all = "camelCase")]
817pub struct ReactionGroup {
818 pub content: ReactionContent,
819 pub count: u32,
820 /// Their usernames, first to react first: `g1t` for g1t.
821 pub users: Vec<String>,
822}
823
824/// Groups `(content, username)` pairs, oldest first, the way they are
825/// shown: in [`ReactionContent::ALL`]'s order, each with its people.
826pub fn group_reactions<'a>(pairs: impl IntoIterator<Item = (ReactionContent, &'a str)>) -> Vec<ReactionGroup> {
827 let mut groups: Vec<ReactionGroup> = Vec::new();
828 for (content, user) in pairs {
829 match groups.iter_mut().find(|group| group.content == content) {
830 Some(group) => {
831 if !group.users.iter().any(|known| known == user) {
832 group.count += 1;
833 group.users.push(user.to_owned());
834 }
835 }
836 None => groups.push(ReactionGroup { content, count: 1, users: vec![user.to_owned()] }),
837 }
838 }
839 groups.sort_by_key(|group| group.content);
840 groups
841}
842
843/// `react`: `actor` reacts with `content` to comment `comment_id` on issue
844/// or pull request `number`, or to its description when `comment_id` is
845/// unset; with `remove`, takes that reaction back. Anyone who can read the
846/// repository may. Returns `Outcome<Vec<ReactionGroup>>`: what it has now.
847#[derive(Debug, Serialize, Deserialize)]
848#[serde(rename_all = "camelCase")]
849pub struct ReactArgs {
850 pub actor: User,
851 pub repo: RepoPath,
852 pub number: u32,
853 #[serde(default)]
854 pub comment_id: Option<String>,
855 pub content: ReactionContent,
856 #[serde(default)]
857 pub remove: bool,
858}
859
860/// What an entry in a conversation is.
861#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
862#[serde(rename_all = "lowercase")]
863pub enum CommentKind {
864 /// Something a person or an agent wrote.
865 #[default]
866 Comment,
867 /// Something that happened: an assignment, a review asked for, a close.
868 Event,
869}
870
871/// A comment on an issue or a pull request. On a pull request it can sit
872/// on one line of the change, and it can carry a reviewer's verdict.
873#[derive(Clone, Debug, Serialize, Deserialize)]
874#[serde(rename_all = "camelCase")]
875pub struct Comment {
876 pub id: String,
877 /// Something a person wrote, or something that happened.
878 #[serde(default)]
879 pub kind: CommentKind,
880 pub author: User,
881 /// Markdown. For an event, what its author did, as the rest of a
882 /// sentence that starts with their name: "assigned ana".
883 pub body: String,
884 /// The file commented on, for a comment on a line.
885 pub path: Option<String>,
886 /// The line of that file, as numbered after the change.
887 pub line: Option<u32>,
888 pub verdict: Option<Verdict>,
889 /// RFC 3339.
890 pub created_at: String,
891 /// When its text was last edited, RFC 3339; absent if it never was.
892 #[serde(default, skip_serializing_if = "Option::is_none")]
893 pub edited_at: Option<String>,
894 /// Set when one of the workspace's agents wrote it, as itself
895 /// (`author` is then the agent: its id, its handle as `username`,
896 /// kind `agent`). Absent for people and for g1t.
897 #[serde(default, skip_serializing_if = "Option::is_none")]
898 pub agent: Option<AgentRef>,
899 /// Who the agent acted for: the person whose access it had, who
900 /// answers for it as its author would. Set with `agent`.
901 #[serde(default, skip_serializing_if = "Option::is_none")]
902 pub acting_for: Option<User>,
903 /// An agent's review: shown with its verdict (or none, for a review
904 /// that only comments) but advisory. It never counts toward required
905 /// approvals or code owners, and never blocks a merge.
906 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
907 pub advisory: bool,
908 /// How people (and g1t) reacted to it.
909 #[serde(default, skip_serializing_if = "Vec::is_empty")]
910 pub reactions: Vec<ReactionGroup>,
911}
912
913impl Comment {
914 /// The person who answers for it: whoever an agent acted for, or its author.
915 pub fn answerable(&self) -> &User {
916 self.acting_for.as_ref().unwrap_or(&self.author)
917 }
918}
919
920/// One of a workspace's own agents, as a comment or review it wrote shows
921/// it: Margo (@margo), with the pixel face drawn from `avatar_seed`. Its
922/// page is `/<workspace>/-/agents/<handle>`. Read in camelCase, like the
923/// comment it sits on; the snake_case spelling is accepted too.
924#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
925#[serde(rename_all = "camelCase")]
926pub struct AgentRef {
927 pub id: String,
928 pub handle: String,
929 #[serde(alias = "display_name")]
930 pub display_name: String,
931 #[serde(alias = "avatar_seed")]
932 pub avatar_seed: String,
933}
934
935/// An agent's review: a comment, an approval or a request for changes,
936/// all of them advisory.
937#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
938#[serde(rename_all = "snake_case")]
939pub enum AgentVerdict {
940 Comment,
941 Approve,
942 RequestChanges,
943}
944
945impl AgentVerdict {
946 pub fn as_str(self) -> &'static str {
947 match self {
948 AgentVerdict::Comment => "comment",
949 AgentVerdict::Approve => "approve",
950 AgentVerdict::RequestChanges => "request_changes",
951 }
952 }
953
954 pub fn parse(text: &str) -> Option<Self> {
955 match text {
956 "comment" => Some(AgentVerdict::Comment),
957 "approve" => Some(AgentVerdict::Approve),
958 "request_changes" => Some(AgentVerdict::RequestChanges),
959 _ => None,
960 }
961 }
962
963 /// As a person's verdict reads, for showing it; none for a comment.
964 pub fn verdict(self) -> Option<Verdict> {
965 match self {
966 AgentVerdict::Comment => None,
967 AgentVerdict::Approve => Some(Verdict::Approve),
968 AgentVerdict::RequestChanges => Some(Verdict::RequestChanges),
969 }
970 }
971}
972
973#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
974#[serde(rename_all = "snake_case")]
975pub enum SessionEntryKind {
976 Prompt,
977 Message,
978 ToolCall,
979 ToolResult,
980 Note,
981 /// A skill the session used: its name only.
982 Skill,
983}
984
985/// One step of an agent's session: the "why" behind a pull request's commits.
986#[derive(Clone, Debug, Serialize, Deserialize)]
987pub struct SessionEntry {
988 pub seq: u32,
989 pub kind: SessionEntryKind,
990 pub text: String,
991 /// For tool calls and results.
992 pub tool: Option<String>,
993 /// The fork's head commit when this entry was recorded, if known.
994 pub commit: Option<String>,
995 /// RFC 3339.
996 pub at: String,
997}
998
999#[derive(Clone, Debug, Serialize, Deserialize)]
1000pub struct NewSessionEntry {
1001 pub kind: SessionEntryKind,
1002 pub text: String,
1003 #[serde(default)]
1004 pub tool: Option<String>,
1005 #[serde(default)]
1006 pub commit: Option<String>,
1007}
1008
1009#[derive(Clone, Debug, Serialize, Deserialize)]
1010pub struct IssueDetail {
1011 pub issue: Issue,
1012 /// Every pull request made against it, oldest first.
1013 pub pulls: Vec<Pull>,
1014 pub comments: Vec<Comment>,
1015 /// How people (and g1t) reacted to its description.
1016 #[serde(default)]
1017 pub reactions: Vec<ReactionGroup>,
1018}
1019
1020#[derive(Clone, Debug, Serialize, Deserialize)]
1021pub struct PullDetail {
1022 pub pull: Pull,
1023 /// The issue it is for, if any.
1024 pub issue: Option<Issue>,
1025 pub comments: Vec<Comment>,
1026 /// How people (and g1t) reacted to its description.
1027 #[serde(default)]
1028 pub reactions: Vec<ReactionGroup>,
1029 /// The latest record against its head: the merge queue taking it out,
1030 /// or (from before checks were workflows) a run of its issue's commands.
1031 pub checks: Option<CheckRun>,
1032 /// Other pull requests in progress that change the same files.
1033 #[serde(default)]
1034 pub overlaps: Vec<Overlap>,
1035 /// Whether the branch it would merge into has moved on without it, so
1036 /// that it has to catch up before it can merge.
1037 #[serde(default)]
1038 pub behind: bool,
1039 /// Where it stands on its way to being merged, for a pull request g1t
1040 /// is seeing through. Absent on anyone else's.
1041 #[serde(default)]
1042 pub lifecycle: Option<Lifecycle>,
1043 /// A merge was asked for while it was behind: g1t is bringing it up to
1044 /// date and will then land it.
1045 #[serde(default)]
1046 pub landing: bool,
1047 /// Why g1t stopped working on it, if it did: a catch-up that could not
1048 /// be completed, for example.
1049 #[serde(default)]
1050 pub stalled: Option<String>,
1051 /// What workflow runs said about its head commit, one per workflow.
1052 #[serde(default)]
1053 pub statuses: Vec<CommitStatus>,
1054 /// Whether it merges cleanly into the branch it targets, worked out
1055 /// ahead of time whenever either side moves.
1056 #[serde(default)]
1057 pub mergeable: Mergeable,
1058 /// When `mergeable` is `conflicting`: the files that conflict.
1059 #[serde(default)]
1060 pub conflicts: Vec<String>,
1061 /// Earlier records like `checks`, newest first, without their output.
1062 #[serde(default)]
1063 pub earlier_checks: Vec<CheckRun>,
1064 /// The checks the default branch's protection requires, each as it
1065 /// stands on the head commit. Empty when none are required.
1066 #[serde(default, alias = "requiredChecks")]
1067 pub required_checks: Vec<RequiredCheck>,
1068 /// The rules of the branch it merges into that it does not meet yet,
1069 /// for whoever is looking. Absent while it is not open.
1070 #[serde(default, skip_serializing_if = "Option::is_none")]
1071 pub rules: Option<crate::rules::MergeRules>,
1072 /// Who owns the files it changes, from the CODEOWNERS file of the
1073 /// branch it merges into, and whose approval is still needed. Absent
1074 /// when that branch has no CODEOWNERS file.
1075 #[serde(default, alias = "codeOwners")]
1076 pub code_owners: Option<crate::codeowners::PullCodeOwners>,
1077}
1078
1079/// Where a required check stands on a commit.
1080#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1081#[serde(rename_all = "lowercase")]
1082pub enum RequiredState {
1083 Success,
1084 Failure,
1085 /// Reported and still running.
1086 Pending,
1087 /// Nothing has reported it on this commit yet.
1088 Expected,
1089}
1090
1091impl RequiredState {
1092 pub fn as_str(self) -> &'static str {
1093 match self {
1094 RequiredState::Success => "success",
1095 RequiredState::Failure => "failure",
1096 RequiredState::Pending => "pending",
1097 RequiredState::Expected => "expected",
1098 }
1099 }
1100}
1101
1102/// One check a branch's protection requires, as it stands on a commit.
1103#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1104#[serde(rename_all = "camelCase")]
1105pub struct RequiredCheck {
1106 /// The check's name, such as `CI` or `g1t / deploy`.
1107 pub name: String,
1108 pub state: RequiredState,
1109 /// What the status that decided it says, if one did.
1110 #[serde(default)]
1111 pub description: Option<String>,
1112 /// Where to see more: the workflow run, for one a workflow reported.
1113 #[serde(default)]
1114 pub target_url: Option<String>,
1115}
1116
1117/// The events a workflow status's context can end in: `CI / pull_request`
1118/// is the `CI` check, reported by a run for a `pull_request` event.
1119const STATUS_EVENTS: &[&str] = &[
1120 "push",
1121 "pull_request",
1122 "pull_request_target",
1123 "pull_request_review",
1124 "merge_group",
1125 "workflow_dispatch",
1126 "workflow_run",
1127 "workflow_call",
1128 "schedule",
1129 "release",
1130 "issues",
1131 "issue_comment",
1132 "repository_dispatch",
1133];
1134
1135/// A status context's check name and the event it was reported for:
1136/// `CI / pull_request` is `("CI", Some("pull_request"))`. A context that
1137/// does not end in an event, such as `g1t / deploy`, is its own name.
1138pub fn check_name(context: &str) -> (&str, Option<&str>) {
1139 match context.rsplit_once(" / ") {
1140 Some((name, event)) if STATUS_EVENTS.contains(&event) && !name.trim().is_empty() => (name, Some(event)),
1141 _ => (context, None),
1142 }
1143}
1144
1145/// Where each required check stands among a commit's statuses. A check is
1146/// met by any status of that name, for any event: one that failed fails
1147/// it, one still running holds it, and with neither, one that passed
1148/// passes it. Names compare without regard to case.
1149pub fn required_checks(required: &[String], statuses: &[CommitStatus]) -> Vec<RequiredCheck> {
1150 required
1151 .iter()
1152 .map(|name| {
1153 let matching: Vec<&CommitStatus> = statuses
1154 .iter()
1155 .filter(|status| check_name(&status.context).0.eq_ignore_ascii_case(name.trim()))
1156 .collect();
1157 let failed = matching.iter().find(|s| s.state == "failure" || s.state == "error");
1158 let pending = matching.iter().find(|s| s.state == "pending");
1159 let passed = matching.iter().find(|s| s.state == "success");
1160 let (state, decided) = match (failed, pending, passed) {
1161 (Some(status), _, _) => (RequiredState::Failure, Some(*status)),
1162 (None, Some(status), _) => (RequiredState::Pending, Some(*status)),
1163 (None, None, Some(status)) => (RequiredState::Success, Some(*status)),
1164 _ => (RequiredState::Expected, None),
1165 };
1166 RequiredCheck {
1167 name: name.trim().to_owned(),
1168 state,
1169 description: decided.and_then(|status| status.description.clone()),
1170 target_url: decided.and_then(|status| status.target_url.clone()),
1171 }
1172 })
1173 .collect()
1174}
1175
1176/// A repository's required check names, tidied: trimmed, without blanks
1177/// or repeats (ignoring case), at most [`MAX_REQUIRED_CHECKS`].
1178pub fn tidy_required(names: &[String]) -> Vec<String> {
1179 let mut out: Vec<String> = Vec::new();
1180 for name in names {
1181 let name: String = name.trim().chars().take(MAX_CHECK_NAME_CHARS).collect();
1182 if !name.is_empty() && !out.iter().any(|kept| kept.eq_ignore_ascii_case(&name)) {
1183 out.push(name);
1184 }
1185 }
1186 out.truncate(MAX_REQUIRED_CHECKS);
1187 out
1188}
1189
1190/// The most checks a branch can require, and the longest name of one.
1191pub const MAX_REQUIRED_CHECKS: usize = 20;
1192pub const MAX_CHECK_NAME_CHARS: usize = 100;
1193
1194/// A check name seen on the repository's commits recently, for choosing
1195/// required checks: what reported it, and for which events.
1196#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1197#[serde(rename_all = "camelCase")]
1198pub struct SeenCheck {
1199 pub name: String,
1200 /// The events it was reported for, such as `pull_request`; empty for a
1201 /// status that names none, such as a deployment's.
1202 pub events: Vec<String>,
1203 /// RFC 3339. The latest report.
1204 pub last_seen: String,
1205}
1206
1207/// `seen_checks`: the check names reported on a repository's commits in
1208/// the last 30 days, most recent first. Returns `Outcome<Vec<SeenCheck>>`.
1209#[derive(Debug, Serialize, Deserialize)]
1210pub struct SeenChecksArgs {
1211 pub repo: RepoPath,
1212 pub viewer: Viewer,
1213}
1214
1215/// The heading an issue's plain-words description of done goes under.
1216pub const DEFINITION_OF_DONE: &str = "## Definition of done";
1217
1218/// An issue's body with `items` added as a bulleted "Definition of done"
1219/// section, for the agent and reviewers to read. Nothing is added when
1220/// `items` is empty, or when the body already has the section, so folding
1221/// the same items twice changes nothing.
1222pub fn with_definition_of_done(body: &str, items: &[String]) -> String {
1223 let items: Vec<&str> = items.iter().map(|item| item.trim()).filter(|item| !item.is_empty()).collect();
1224 let body = body.trim();
1225 if items.is_empty() || body.contains(DEFINITION_OF_DONE) {
1226 return body.to_owned();
1227 }
1228 let list = items.iter().map(|item| format!("- {item}")).collect::<Vec<_>>().join("\n");
1229 if body.is_empty() {
1230 format!("{DEFINITION_OF_DONE}\n\n{list}")
1231 } else {
1232 format!("{body}\n\n{DEFINITION_OF_DONE}\n\n{list}")
1233 }
1234}
1235
1236/// Commands, as items of a definition of done: "`npm test` passes."
1237pub fn commands_pass(commands: &[String]) -> Vec<String> {
1238 commands
1239 .iter()
1240 .map(|command| command.trim())
1241 .filter(|command| !command.is_empty())
1242 .map(|command| format!("`{}` passes.", command.replace('`', "'")))
1243 .collect()
1244}
1245
1246/// Whether a pull request's change merges cleanly into the branch it
1247/// targets.
1248#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1249#[serde(rename_all = "lowercase")]
1250pub enum Mergeable {
1251 /// It merges without conflicts.
1252 Clean,
1253 /// Some files conflict: see `PullDetail::conflicts`.
1254 Conflicting,
1255 /// Not known: never worked out, or it could not be.
1256 #[default]
1257 Unknown,
1258 /// Being worked out now.
1259 Checking,
1260}
1261
1262impl Mergeable {
1263 pub fn as_str(self) -> &'static str {
1264 match self {
1265 Mergeable::Clean => "clean",
1266 Mergeable::Conflicting => "conflicting",
1267 Mergeable::Unknown => "unknown",
1268 Mergeable::Checking => "checking",
1269 }
1270 }
1271
1272 pub fn parse(value: Option<&str>) -> Mergeable {
1273 match value {
1274 Some("clean") => Mergeable::Clean,
1275 Some("conflicting") => Mergeable::Conflicting,
1276 Some("checking") => Mergeable::Checking,
1277 _ => Mergeable::Unknown,
1278 }
1279 }
1280}
1281
1282/// `start_mergecheck`: claims the probe of whether a pull request merges
1283/// cleanly, which the work service asked for with a `pull.mergecheck`
1284/// event. Called by the runner service, which starts the sandbox. Refused
1285/// when it is no longer wanted, or when the repository already has as many
1286/// probes running as it may. Returns `Outcome<MergecheckJob>`.
1287#[derive(Debug, Serialize, Deserialize)]
1288#[serde(rename_all = "camelCase")]
1289pub struct StartMergecheckArgs {
1290 pub pull_id: String,
1291}
1292
1293/// What a sandbox needs to find out whether a pull request merges cleanly.
1294#[derive(Debug, Serialize, Deserialize)]
1295#[serde(rename_all = "camelCase")]
1296pub struct MergecheckJob {
1297 pub pull_id: String,
1298 /// Lets the sandbox, and nothing else, report this probe.
1299 pub token: String,
1300 pub repo: RepoPath,
1301 pub number: u32,
1302 pub default_branch: String,
1303 /// The default branch's commit to merge into.
1304 pub base: String,
1305 /// The repository holding the change: its fork, or the repository.
1306 pub source: RepoPath,
1307 /// The branch of `source` holding it.
1308 pub branch: String,
1309 /// The change's commit.
1310 pub head: String,
1311 /// Who the pull request is for ([`Pull::owner`]: whoever asked g1t for
1312 /// it, or its author), and so can read its source.
1313 pub author: User,
1314}
1315
1316/// `report_mergecheck`: what a sandbox found. Returns `Outcome<Mergeable>`.
1317#[derive(Debug, Serialize, Deserialize)]
1318#[serde(rename_all = "camelCase")]
1319pub struct ReportMergecheckArgs {
1320 pub pull_id: String,
1321 pub token: String,
1322 /// The files that conflict; empty when it merges cleanly.
1323 #[serde(default)]
1324 pub conflicts: Vec<String>,
1325 /// Why it could not be found out.
1326 #[serde(default)]
1327 pub error: Option<String>,
1328}
1329
1330/// What a workflow run (or another tool) says about a commit.
1331#[derive(Clone, Debug, Default, Serialize, Deserialize)]
1332#[serde(rename_all = "camelCase")]
1333pub struct CommitStatus {
1334 /// What reported it, such as `CI / push`.
1335 pub context: String,
1336 /// `pending`, `success`, `failure` or `error`.
1337 pub state: String,
1338 pub description: Option<String>,
1339 /// Where to see more, such as the run's page.
1340 pub target_url: Option<String>,
1341 pub updated_at: String,
1342 /// The integration that reported it (`actions`, `deployments`,
1343 /// `security`, `g1t`), when recorded. A required check can insist on
1344 /// one (`rules::RequiredCheck::integration`).
1345 #[serde(default, skip_serializing_if = "Option::is_none")]
1346 pub source: Option<String>,
1347 /// Set on the status a check run stands as (see `checks`): the check
1348 /// run's id. Such a status is listed as its check run, not again.
1349 #[serde(default, skip_serializing_if = "Option::is_none")]
1350 pub check_run_id: Option<String>,
1351}
1352
1353/// `set_commit_status`: for services only. Returns `Outcome<bool>`.
1354#[derive(Debug, Serialize, Deserialize)]
1355#[serde(rename_all = "camelCase")]
1356pub struct SetCommitStatusArgs {
1357 pub repo_id: String,
1358 pub sha: String,
1359 pub context: String,
1360 pub state: String,
1361 #[serde(default)]
1362 pub description: Option<String>,
1363 #[serde(default)]
1364 pub target_url: Option<String>,
1365 /// The integration reporting it: `actions`, `deployments`, `security`
1366 /// or `g1t`.
1367 #[serde(default)]
1368 pub source: Option<String>,
1369}
1370
1371/// A step on the way from an assigned issue to a pull request that is ready
1372/// to merge. g1t takes each one without being asked.
1373#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1374#[serde(rename_all = "snake_case")]
1375pub enum Stage {
1376 /// The agent is making the change.
1377 Working,
1378 /// Waiting for the checks workflows report on its head.
1379 Checking,
1380 /// g1t is reviewing it.
1381 Reviewing,
1382 /// The agent is addressing failed checks or a review.
1383 Revising,
1384 /// The agent is merging in the branch it would land on, which moved.
1385 CatchingUp,
1386 /// Woken to answer a question another agent asked it, or a handoff.
1387 Answering,
1388 /// In the repository's merge queue, being tested with what is ahead of
1389 /// it before it lands.
1390 Queued,
1391 /// Required checks passed, reviewed and approved, up to date. A
1392 /// person merges.
1393 Ready,
1394 /// g1t has stopped and a person has to decide what happens next.
1395 NeedsYou,
1396}
1397
1398/// Where a pull request g1t's retired agents made stands, for those still
1399/// open. See [`Stage`].
1400#[derive(Clone, Debug, Serialize, Deserialize)]
1401pub struct Lifecycle {
1402 pub stage: Stage,
1403 /// One sentence saying what is happening, or why it stopped.
1404 pub detail: String,
1405 /// How many times the agent has been sent back to revise it.
1406 pub revisions: u32,
1407}
1408
1409/// `advance`: merges a pull request g1t is seeing through once it is ready,
1410/// where the repository's settings say ready pull requests land by
1411/// themselves. Called by the runner service. Returns `Advance`.
1412#[derive(Debug, Serialize, Deserialize)]
1413#[serde(rename_all = "camelCase")]
1414pub struct AdvanceArgs {
1415 pub pull_id: String,
1416}
1417
1418/// What the runner is to do next for a pull request: always nothing now,
1419/// since no step of a pull request is carried out in a sandbox any more.
1420#[derive(Debug, Serialize, Deserialize)]
1421#[serde(tag = "action", rename_all = "snake_case")]
1422pub enum Advance {
1423 /// Nothing to do.
1424 None,
1425}
1426
1427/// How a repository wants its pull requests handled. A repository that has
1428/// changed nothing has the defaults.
1429#[derive(Clone, Debug, Serialize, Deserialize)]
1430#[serde(rename_all = "camelCase", default)]
1431pub struct RepoSettings {
1432 /// Land a pull request g1t is seeing through without a person once it
1433 /// is ready: required checks passed and approved as the settings below
1434 /// require.
1435 pub auto_merge: bool,
1436 /// The checks that must pass on a pull request's head before it may
1437 /// merge into the default branch, by name: a workflow's name (`CI`), or
1438 /// the context of another status (`g1t / deploy`). The same for a
1439 /// person's pull request and an agent's, and for the merge queue.
1440 pub required_checks: Vec<String>,
1441 /// Refuse to merge a pull request that does not contain the default
1442 /// branch's latest commits, so that what merges is what was checked.
1443 /// When off, merging one that is behind brings it up to date first.
1444 pub require_up_to_date: bool,
1445 /// How many approving reviews a pull request needs before it may
1446 /// merge. A reviewer who has since asked for changes blocks it.
1447 pub required_approvals: u32,
1448 /// Whether an agent's approval counts towards `required_approvals`.
1449 pub count_agent_approvals: bool,
1450 /// Whether someone who may merge can bypass required checks that have
1451 /// not passed, by saying so as they merge.
1452 pub allow_ignoring_checks: bool,
1453 /// Merge through a queue: pull requests are tested together with those
1454 /// ahead of them, and only a combination that passed reaches the default
1455 /// branch.
1456 pub merge_queue: bool,
1457 /// Refuse to merge until the code owners of every file it changes
1458 /// (its CODEOWNERS file) have approved, as many as each section asks.
1459 /// Only people count, and `g1t` only where the file names `@g1t`.
1460 pub require_code_owner_review: bool,
1461 /// Whether issues can be opened in it: null for the default, which is
1462 /// on for a repository and off for a fork of one. Existing issues stay
1463 /// readable either way.
1464 pub has_issues: Option<bool>,
1465 /// Whether issues can be opened in it now: `has_issues`, or its
1466 /// default. Filled in by the service; `update_settings` ignores it.
1467 pub issues_enabled: bool,
1468 /// Which merge methods a pull request may use (at least one). Rules
1469 /// can narrow them for a branch (`allowed_merge_methods`).
1470 #[serde(default = "yes")]
1471 pub allow_merge_commit: bool,
1472 #[serde(default = "yes")]
1473 pub allow_squash_merge: bool,
1474 #[serde(default = "yes")]
1475 pub allow_rebase_merge: bool,
1476 #[serde(default)]
1477 pub allow_fast_forward: bool,
1478 /// The method offered first, and used when none is named.
1479 #[serde(default = "merge_method")]
1480 pub default_merge_method: MergeMethod,
1481 /// What merge and squash commits say unless their merger writes it.
1482 #[serde(default)]
1483 pub merge_commit_message: CommitMessageDefault,
1484 #[serde(default)]
1485 pub squash_commit_message: CommitMessageDefault,
1486 /// For `Custom`: a template whose `{title}`, `{number}`, `{body}`,
1487 /// `{branch}`, `{head_repo}`, `{base}`, `{author}`, `{commits}`,
1488 /// `{issues}` and `{co_authors}` are filled in.
1489 #[serde(default)]
1490 pub merge_commit_template: Option<String>,
1491 #[serde(default)]
1492 pub squash_commit_template: Option<String>,
1493 /// Offer to bring a pull request up to date whenever its base has new
1494 /// commits, not only when it must be.
1495 #[serde(default = "yes")]
1496 pub suggest_updating_branches: bool,
1497 /// Let people turn on auto-merge for a pull request.
1498 #[serde(default = "yes")]
1499 pub allow_auto_merge: bool,
1500 /// Delete a pull request's branch once it merges (restorable from it).
1501 #[serde(default)]
1502 pub delete_branch_on_merge: bool,
1503 /// Commits made on g1t.sh (web edits, merges, squashes) carry a
1504 /// `Signed-off-by:` trailer for whoever made them, agreeing to the
1505 /// repository's terms, commonly the Developer Certificate of Origin.
1506 #[serde(default)]
1507 pub require_web_commit_sign_off: bool,
1508 /// Let anyone who can read it comment on its commits. Existing
1509 /// comments stay readable either way.
1510 #[serde(default = "yes")]
1511 pub allow_commit_comments: bool,
1512 /// Close the issues a pull request names (its issue, and `Fixes #12`
1513 /// in its title or description) when it merges into the default
1514 /// branch.
1515 #[serde(default = "yes")]
1516 pub auto_close_issues: bool,
1517 /// Username of the member who last changed the settings, if anyone has.
1518 pub updated_by: Option<String>,
1519 /// RFC 3339.
1520 pub updated_at: Option<String>,
1521}
1522
1523impl Default for RepoSettings {
1524 fn default() -> Self {
1525 RepoSettings {
1526 auto_merge: false,
1527 required_checks: Vec::new(),
1528 require_up_to_date: false,
1529 required_approvals: 0,
1530 count_agent_approvals: true,
1531 allow_ignoring_checks: true,
1532 merge_queue: false,
1533 require_code_owner_review: false,
1534 has_issues: None,
1535 issues_enabled: true,
1536 allow_merge_commit: true,
1537 allow_squash_merge: true,
1538 allow_rebase_merge: true,
1539 allow_fast_forward: false,
1540 default_merge_method: MergeMethod::Merge,
1541 merge_commit_message: CommitMessageDefault::Default,
1542 squash_commit_message: CommitMessageDefault::Default,
1543 merge_commit_template: None,
1544 squash_commit_template: None,
1545 suggest_updating_branches: true,
1546 allow_auto_merge: true,
1547 delete_branch_on_merge: false,
1548 require_web_commit_sign_off: false,
1549 allow_commit_comments: true,
1550 auto_close_issues: true,
1551 updated_by: None,
1552 updated_at: None,
1553 }
1554 }
1555}
1556
1557impl RepoSettings {
1558 /// Whether issues can be opened: as `has_issues` says, else on unless
1559 /// the repository is a fork (`is_fork`).
1560 pub fn issues_on(&self, is_fork: bool) -> bool {
1561 self.has_issues.unwrap_or(!is_fork)
1562 }
1563
1564 /// Whether the repository lets pull requests merge by `method`.
1565 pub fn allows(&self, method: MergeMethod) -> bool {
1566 match method {
1567 MergeMethod::Merge => self.allow_merge_commit,
1568 MergeMethod::Squash => self.allow_squash_merge,
1569 MergeMethod::Rebase => self.allow_rebase_merge,
1570 MergeMethod::FastForward => self.allow_fast_forward,
1571 }
1572 }
1573
1574 /// The methods it allows, in the order they are offered.
1575 pub fn allowed_methods(&self) -> Vec<MergeMethod> {
1576 MergeMethod::ALL.into_iter().filter(|method| self.allows(*method)).collect()
1577 }
1578}
1579
1580fn yes() -> bool {
1581 true
1582}
1583
1584fn merge_method() -> MergeMethod {
1585 MergeMethod::Merge
1586}
1587
1588/// A comment on a commit: on the whole commit, or on one line of a file it
1589/// changed (`path` and `line`, a line of the file as the commit left it).
1590#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1591#[serde(rename_all = "camelCase")]
1592pub struct CommitComment {
1593 pub id: String,
1594 pub repo_id: String,
1595 /// The commit's full hash.
1596 pub commit: String,
1597 #[serde(default)]
1598 pub path: Option<String>,
1599 #[serde(default)]
1600 pub line: Option<u32>,
1601 /// Username.
1602 pub author: String,
1603 /// Markdown.
1604 pub body: String,
1605 /// RFC 3339.
1606 pub created_at: String,
1607 #[serde(default)]
1608 pub updated_at: Option<String>,
1609}
1610
1611/// `commit_comments`: a commit's comments, oldest first; with no `commit`,
1612/// the repository's, newest first, a page of 30 at a time. Returns
1613/// `Outcome<Vec<CommitComment>>`.
1614#[derive(Debug, Serialize, Deserialize)]
1615pub struct CommitCommentsArgs {
1616 pub viewer: Viewer,
1617 pub repo: RepoPath,
1618 #[serde(default)]
1619 pub commit: Option<String>,
1620 #[serde(default)]
1621 pub page: Option<u32>,
1622}
1623
1624/// `add_commit_comment`: needs Read (refused where the repository turned
1625/// commit comments off), and the commit must exist. Notifies the commit's
1626/// author and anyone mentioned; publishes `commit_comment.created`.
1627/// Returns `Outcome<CommitComment>`.
1628#[derive(Debug, Serialize, Deserialize)]
1629pub struct AddCommitCommentArgs {
1630 pub actor: User,
1631 pub repo: RepoPath,
1632 pub commit: String,
1633 pub body: String,
1634 #[serde(default)]
1635 pub path: Option<String>,
1636 #[serde(default)]
1637 pub line: Option<u32>,
1638}
1639
1640/// `edit_commit_comment` (its author) and `delete_commit_comment` (its
1641/// author, or Maintain and up). `body` is ignored when deleting. Returns
1642/// `Outcome<CommitComment>` and `Outcome<bool>`.
1643#[derive(Debug, Serialize, Deserialize)]
1644pub struct CommitCommentArgs {
1645 pub actor: User,
1646 pub repo: RepoPath,
1647 pub id: String,
1648 #[serde(default)]
1649 pub body: String,
1650}
1651
1652/// `update_settings`: replaces a repository's settings. Members of its
1653/// workspace only. Returns `Outcome<RepoSettings>`. `get_settings` takes
1654/// `ViewArgs` and returns the same.
1655#[derive(Debug, Serialize, Deserialize)]
1656pub struct UpdateSettingsArgs {
1657 pub actor: User,
1658 pub repo: RepoPath,
1659 /// Who changed them and when are filled in by the service.
1660 pub settings: RepoSettings,
1661}
1662
1663/// What the work service records when a step could not be carried out, so
1664/// that g1t stops and a person is asked. Not served over RPC.
1665#[derive(Debug, Serialize, Deserialize)]
1666#[serde(rename_all = "camelCase")]
1667pub struct StallArgs {
1668 pub pull_id: String,
1669 pub reason: String,
1670 /// The person who stopped it, by id, when someone did: they are not
1671 /// told it needs them.
1672 #[serde(default)]
1673 pub by: Option<String>,
1674}
1675
1676/// `managed_pulls`: ids of the open pull requests to move along (`advance`):
1677/// those g1t merges by itself or someone set to auto-merge, in one
1678/// repository or in all of them; in one repository, also every open one
1679/// whose lifecycle is remembered, to bring it up to date after a merge
1680/// there. Returns `Vec<String>`.
1681#[derive(Debug, Default, Serialize, Deserialize)]
1682#[serde(rename_all = "camelCase")]
1683pub struct ManagedPullsArgs {
1684 #[serde(default)]
1685 pub repo_id: Option<String>,
1686}
1687
1688/// `open_issue`. Returns `Outcome<Issue>`.
1689#[derive(Debug, Serialize, Deserialize)]
1690pub struct OpenIssueArgs {
1691 pub actor: User,
1692 pub repo: RepoPath,
1693 pub title: String,
1694 #[serde(default)]
1695 pub body: String,
1696 #[serde(default)]
1697 pub labels: Vec<String>,
1698 /// Deprecated: commands, added to the body under "Definition of done".
1699 /// Checks are the workflows the branch's protection requires.
1700 #[serde(default)]
1701 pub checks: Vec<String>,
1702 /// The number of the milestone to put it in. Needs the Triage role.
1703 #[serde(default)]
1704 pub milestone: Option<u32>,
1705}
1706
1707/// `list_issues`, newest first. Returns `Outcome<Vec<Issue>>`.
1708#[derive(Debug, Serialize, Deserialize)]
1709pub struct ListIssuesArgs {
1710 pub repo: RepoPath,
1711 pub viewer: Viewer,
1712 #[serde(default)]
1713 pub state: Option<State>,
1714 /// Only issues carrying this label.
1715 #[serde(default)]
1716 pub label: Option<String>,
1717 /// Only issues in the milestone of this number.
1718 #[serde(default)]
1719 pub milestone: Option<u32>,
1720}
1721
1722/// `list_pulls`, newest first. Returns `Outcome<Vec<Pull>>`.
1723#[derive(Debug, Serialize, Deserialize)]
1724pub struct ListPullsArgs {
1725 pub repo: RepoPath,
1726 pub viewer: Viewer,
1727 #[serde(default)]
1728 pub state: Option<State>,
1729 /// Only pull requests carrying this label.
1730 #[serde(default)]
1731 pub label: Option<String>,
1732 /// Only pull requests in the milestone of this number.
1733 #[serde(default)]
1734 pub milestone: Option<u32>,
1735 /// Only pull requests into this branch.
1736 #[serde(default)]
1737 pub base: Option<String>,
1738}
1739
1740/// `pulls_for_repos`: the newest open and the newest closed pull requests
1741/// of many repositories, in one call, for pages that show several projects
1742/// at once. Repositories the viewer cannot read are left out, as are forks
1743/// (ask those with `list_pulls`). Returns `Vec<RepoPulls>`.
1744#[derive(Debug, Serialize, Deserialize)]
1745#[serde(rename_all = "camelCase")]
1746pub struct PullsForReposArgs {
1747 /// At most [`MAX_PULLS_FOR_REPOS`] are looked at.
1748 pub repo_ids: Vec<String>,
1749 pub viewer: Viewer,
1750 /// How many of each, open and closed, per repository (at most 100).
1751 pub limit: u32,
1752}
1753
1754/// The most repositories one `pulls_for_repos` call looks at.
1755pub const MAX_PULLS_FOR_REPOS: usize = 50;
1756
1757/// One repository's pull requests from `pulls_for_repos`, newest first.
1758#[derive(Debug, Serialize, Deserialize)]
1759#[serde(rename_all = "camelCase")]
1760pub struct RepoPulls {
1761 pub repo_id: String,
1762 /// Draft and open.
1763 pub open: Vec<Pull>,
1764 /// Merged and closed.
1765 pub closed: Vec<Pull>,
1766}
1767
1768/// `get_issue` (`Outcome<IssueDetail>`), `get_pull` (`Outcome<PullDetail>`),
1769/// `read_session` (`Outcome<Vec<SessionEntry>>`), `list_labels`
1770/// (`Outcome<Vec<String>>`) and `counts` (`Outcome<Counts>`). The last two
1771/// ignore `number`.
1772#[derive(Debug, Serialize, Deserialize)]
1773#[serde(rename_all = "camelCase")]
1774pub struct ViewArgs {
1775 pub repo: RepoPath,
1776 #[serde(default)]
1777 pub number: u32,
1778 pub viewer: Viewer,
1779 /// For `read_session`: only entries after this sequence number.
1780 #[serde(default)]
1781 pub after_seq: u32,
1782}
1783
1784/// How many issues and pull requests are open on a repository.
1785#[derive(Debug, Serialize, Deserialize)]
1786pub struct Counts {
1787 pub issues: u32,
1788 pub pulls: u32,
1789}
1790
1791/// `update_issue`: changes whichever fields are given. The author or a
1792/// member of the workspace may. Returns `Outcome<Issue>`.
1793#[derive(Debug, Serialize, Deserialize)]
1794pub struct UpdateIssueArgs {
1795 pub actor: User,
1796 pub repo: RepoPath,
1797 pub number: u32,
1798 #[serde(default)]
1799 pub title: Option<String>,
1800 #[serde(default)]
1801 pub body: Option<String>,
1802 #[serde(default)]
1803 pub labels: Option<Vec<String>>,
1804 /// Usernames of the people it is assigned to; replaces the whole set.
1805 #[serde(default)]
1806 pub assignees: Option<Vec<String>>,
1807 /// The number of the milestone to put it in; 0 takes it out of its
1808 /// milestone. Needs the Triage role.
1809 #[serde(default)]
1810 pub milestone: Option<u32>,
1811 /// The numbers of the issues of the same repository that have to land
1812 /// first; replaces the whole set. Refused when one is not an issue, is
1813 /// the issue itself, or would make a cycle.
1814 #[serde(default)]
1815 pub blocked_by: Option<Vec<u32>>,
1816}
1817
1818/// `close_issue` and `reopen_issue`. Each returns `Outcome<Issue>`.
1819#[derive(Debug, Serialize, Deserialize)]
1820pub struct IssueActionArgs {
1821 pub actor: User,
1822 pub repo: RepoPath,
1823 pub number: u32,
1824 /// For `close_issue`; `completed` if left out.
1825 #[serde(default)]
1826 pub reason: Option<IssueReason>,
1827}
1828
1829/// `add_comment`, on an issue or a pull request. On a pull request it may
1830/// name a line of the change, and may carry a verdict; nobody can give a
1831/// verdict on their own pull request. Returns `Outcome<Comment>`.
1832#[derive(Debug, Serialize, Deserialize)]
1833pub struct AddCommentArgs {
1834 pub actor: User,
1835 pub repo: RepoPath,
1836 pub number: u32,
1837 /// May be empty when approving.
1838 #[serde(default)]
1839 pub body: String,
1840 #[serde(default)]
1841 pub path: Option<String>,
1842 #[serde(default)]
1843 pub line: Option<u32>,
1844 #[serde(default)]
1845 pub verdict: Option<Verdict>,
1846}
1847
1848/// `edit_comment` and `delete_comment`: change the text of a comment on an
1849/// issue or a pull request, or delete it. Its author may, and so may anyone
1850/// with the Maintain role or higher ([`may_change_comment`]). Notes of what
1851/// happened (`CommentKind::Event`) cannot be changed, and a review that
1852/// gave a verdict cannot be deleted, only edited. `edit_comment` returns
1853/// `Outcome<Comment>`; `delete_comment` returns `Outcome<()>`.
1854#[derive(Debug, Serialize, Deserialize)]
1855#[serde(rename_all = "camelCase")]
1856pub struct CommentActionArgs {
1857 pub actor: User,
1858 pub repo: RepoPath,
1859 pub comment_id: String,
1860 /// For `edit_comment`: the new text, in Markdown.
1861 #[serde(default)]
1862 pub body: String,
1863}
1864
1865/// Why `actor_id` may not edit (or, with `deleting`, delete) a comment by
1866/// `author_id`, or none when they may: its author may, and so may someone
1867/// who `maintains` the repository (the Maintain role or higher). A note of
1868/// something that happened is never changed, and a review's verdict stays,
1869/// so a comment that gave one can be edited but not deleted.
1870pub fn may_change_comment(
1871 kind: CommentKind,
1872 has_verdict: bool,
1873 author_id: &str,
1874 actor_id: &str,
1875 maintains: bool,
1876 deleting: bool,
1877) -> Result<(), &'static str> {
1878 if kind == CommentKind::Event {
1879 return Err("That is a record of something that happened, not a comment; it cannot be changed.");
1880 }
1881 if author_id != actor_id && !maintains {
1882 return Err(if deleting {
1883 "Only its author, or someone with the Maintain role or higher, can delete this comment."
1884 } else {
1885 "Only its author, or someone with the Maintain role or higher, can edit this comment."
1886 });
1887 }
1888 if deleting && has_verdict {
1889 return Err("A review cannot be deleted. Edit its text instead.");
1890 }
1891 Ok(())
1892}
1893
1894/// `open_pull`. Without `branch`, forks the repo and returns a draft pull
1895/// request to push to. With it, opens a pull request, ready for review,
1896/// for a branch already pushed to the repo, or to `head_repo`, a fork of
1897/// it. Returns `Outcome<Pull>`.
1898#[derive(Debug, Serialize, Deserialize)]
1899pub struct OpenPullArgs {
1900 pub actor: User,
1901 pub repo: RepoPath,
1902 /// The issue this is for.
1903 #[serde(default)]
1904 pub issue: Option<u32>,
1905 /// Defaults to the issue's title; required without an issue.
1906 #[serde(default)]
1907 pub title: String,
1908 /// What changed and why. Usually set later, when a draft is marked ready.
1909 #[serde(default)]
1910 pub body: String,
1911 /// A branch of the repository that already holds the change: of
1912 /// `head_repo` when given.
1913 #[serde(default)]
1914 pub branch: Option<String>,
1915 /// A fork of the repository (directly or through other forks) whose
1916 /// `branch` holds the change. Needs `branch`.
1917 #[serde(default)]
1918 pub head_repo: Option<RepoPath>,
1919 #[serde(default)]
1920 pub agent: String,
1921 pub runtime: Runtime,
1922 /// The branch to merge into: the default branch when absent.
1923 #[serde(default)]
1924 pub base: Option<String>,
1925 /// Opened from a branch as a draft, still being worked on: it can't
1926 /// merge, and agents' review routines wait, until it is marked ready.
1927 #[serde(default)]
1928 pub draft: bool,
1929}
1930
1931/// A person's request that a pull request merge on its own once its
1932/// checks, reviews and rules allow (`set_auto_merge`). Cleared when it
1933/// merges or closes, when someone turns it off, and when someone other
1934/// than who asked pushes to it.
1935#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1936#[serde(rename_all = "camelCase")]
1937pub struct AutoMerge {
1938 pub method: MergeMethod,
1939 /// The commit message's first line and the rest, for `merge` and
1940 /// `squash`; the repository's default when absent.
1941 #[serde(default)]
1942 pub commit_title: Option<String>,
1943 #[serde(default)]
1944 pub commit_message: Option<String>,
1945 /// Username of who asked.
1946 pub enabled_by: String,
1947 /// RFC 3339.
1948 pub enabled_at: String,
1949}
1950
1951/// `set_auto_merge`: turns a pull request's auto-merge on (with how to
1952/// merge) or off. Turning it on needs the Write role, since it merges as
1953/// whoever turned it on; its author may also turn it off. Refused where
1954/// the repository does not allow auto-merge, and for a method the
1955/// repository or its rules do not allow. A pull request that may merge
1956/// already merges at once instead. Publishes `pull.auto_merge_enabled` or
1957/// `pull.auto_merge_disabled`. Returns `Outcome<Pull>`.
1958#[derive(Debug, Serialize, Deserialize)]
1959#[serde(rename_all = "camelCase")]
1960pub struct SetAutoMergeArgs {
1961 pub actor: User,
1962 pub repo: RepoPath,
1963 pub number: u32,
1964 pub enabled: bool,
1965 /// The repository's default method when absent.
1966 #[serde(default)]
1967 pub merge_method: Option<MergeMethod>,
1968 #[serde(default)]
1969 pub commit_title: Option<String>,
1970 #[serde(default)]
1971 pub commit_message: Option<String>,
1972}
1973
1974/// `merge_choices`: how a pull request may merge now, for whoever is
1975/// looking: the merge box's choices and their default messages. Takes
1976/// `ViewArgs`. Returns `Outcome<MergeChoices>`.
1977#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
1978#[serde(rename_all = "camelCase")]
1979pub struct MergeChoices {
1980 /// The methods it may merge by: the repository's, as the rules for the
1981 /// branch it merges into allow (linear history rules out merge
1982 /// commits). In the order offered.
1983 pub methods: Vec<MergeMethod>,
1984 /// The one offered first: the repository's default when it may be
1985 /// used, else the first of `methods`. Null when none may.
1986 pub default_method: Option<MergeMethod>,
1987 /// Each of `methods`, with its default commit message and whether it
1988 /// can be used now.
1989 pub options: Vec<MergeChoice>,
1990 /// Its base has commits it does not have.
1991 pub behind: bool,
1992 /// It contains its base's head, so it can fast-forward as it is.
1993 pub can_fast_forward: bool,
1994 /// Offer to bring it up to date: it is behind, and the repository
1995 /// suggests updating branches (or requires them up to date).
1996 pub suggest_update: bool,
1997 /// The repository requires it to be up to date before it merges.
1998 pub require_up_to_date: bool,
1999 /// It merges through the merge queue, which builds and tests it by the
2000 /// method chosen (the queue rule's when none is) and lands it as tested.
2001 pub merge_queue: bool,
2002 /// The viewer may turn on auto-merge for it: the repository allows it,
2003 /// it is open or a draft, and they may merge it.
2004 pub auto_merge_allowed: bool,
2005 /// Whether "delete the branch" starts ticked: the repository deletes
2006 /// branches on merge, and its branch can be deleted.
2007 pub delete_branch_default: bool,
2008 /// Its branch can be deleted once it merges: a branch of the
2009 /// repository (not its base) or of a fork, never a working copy.
2010 pub can_delete_branch: bool,
2011 /// Commits made merging it carry a `Signed-off-by:` trailer for the
2012 /// merger.
2013 pub sign_off: bool,
2014}
2015
2016/// One way a pull request may merge (`MergeChoices::options`).
2017#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
2018#[serde(rename_all = "camelCase")]
2019pub struct MergeChoice {
2020 pub method: MergeMethod,
2021 /// For `merge` and `squash`: the commit's title and the rest of its
2022 /// message, as the repository's settings write them.
2023 #[serde(default)]
2024 pub commit_title: Option<String>,
2025 #[serde(default)]
2026 pub commit_message: Option<String>,
2027 /// Why it cannot merge this way now, if it cannot.
2028 #[serde(default)]
2029 pub unavailable: Option<String>,
2030}
2031
2032/// `ready_pull`, `close_pull`, `reopen_pull`, `convert_pull_to_draft` and
2033/// `merge_pull`. Each returns `Outcome<Pull>`. `reopen_pull` opens a closed
2034/// pull request again (never a merged one), as the draft it was if it was
2035/// closed as one; `convert_pull_to_draft` turns one that is open back into
2036/// a draft, which leaves the merge queue.
2037///
2038/// Also `delete_head_branch` (a merged or closed one's branch, for whoever
2039/// may push where it is; never a working copy's, which repos retires) and
2040/// `restore_head_branch` (makes it again at `head_commit`). Both publish
2041/// `pull.head_branch_deleted` or `pull.head_branch_restored` and return
2042/// `Outcome<Pull>`.
2043///
2044/// Also `catch_up_pull`: brings the pull request up to date with the
2045/// default branch without a sandbox where that is safe, as the repos
2046/// service's `update_pull_branch` does, after checking that `actor` may
2047/// update it: its owner ([`Pull::owner`]) for a fork, any member for a
2048/// branch.
2049/// Returns `Outcome<repos::PullBranchUpdate>`; on `needs_agent` nothing was
2050/// pushed and the runner's `update` is the way on.
2051#[derive(Debug, Serialize, Deserialize)]
2052#[serde(rename_all = "camelCase")]
2053pub struct PullActionArgs {
2054 pub actor: User,
2055 pub repo: RepoPath,
2056 pub number: u32,
2057 /// For `ready_pull`: what changed and why.
2058 #[serde(default)]
2059 pub summary: String,
2060 /// For `merge_pull`: leave the issue open and the other pull requests
2061 /// for it untouched, because this one is only part of the work.
2062 #[serde(default)]
2063 pub keep_issue_open: bool,
2064 /// For `merge_pull`: merge although required checks have not passed,
2065 /// where the repository lets members bypass them.
2066 #[serde(default)]
2067 pub ignore_checks: bool,
2068 /// For `merge_pull`: merge although rules are not met, where a ruleset
2069 /// lists the actor as one who may bypass it. Recorded as a bypass.
2070 #[serde(default)]
2071 pub bypass_rules: bool,
2072 /// For `merge_pull`: how to merge; the repository's default method
2073 /// when absent. Refused when the repository or a rule does not allow
2074 /// it.
2075 #[serde(default)]
2076 pub merge_method: Option<MergeMethod>,
2077 /// For `merge_pull` with `merge` or `squash`: the commit's title and
2078 /// the rest of its message; the repository's default when absent.
2079 #[serde(default)]
2080 pub commit_title: Option<String>,
2081 #[serde(default)]
2082 pub commit_message: Option<String>,
2083 /// For `merge_pull` (and `catch_up_pull`): refuse if the pull
2084 /// request's head is no longer this commit.
2085 #[serde(default)]
2086 pub expected_head: Option<String>,
2087 /// For `merge_pull`: delete its branch once merged; the repository's
2088 /// setting when absent.
2089 #[serde(default)]
2090 pub delete_branch: Option<bool>,
2091 /// For `catch_up_pull`: merge the base in (the default) or rebase.
2092 #[serde(default)]
2093 pub update_method: Option<UpdateMethod>,
2094}
2095
2096/// What a merge or squash commit's message says by default.
2097#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
2098#[serde(rename_all = "snake_case")]
2099pub enum CommitMessageDefault {
2100 /// Merge: "Merge pull request #12 from owner/branch" and its title.
2101 /// Squash: its title for one commit, else its title and the commits'
2102 /// messages.
2103 #[default]
2104 Default,
2105 /// Its title, with its number.
2106 PullTitle,
2107 /// Its title, and its description below.
2108 PullTitleAndDescription,
2109 /// Squash only: its title, and each commit's message below.
2110 PullTitleAndCommits,
2111 /// The repository's own template (`merge_commit_template` or
2112 /// `squash_commit_template`).
2113 Custom,
2114}
2115
2116/// `update_pull`: changes who a pull request is assigned to, whose review
2117/// is asked for, its labels, its milestone and the branch it merges into.
2118/// Each list given replaces the whole set. Whoever opened it, or someone
2119/// with the Triage role, may; changing the base needs the Write role.
2120/// Returns `Outcome<Pull>`.
2121#[derive(Debug, Serialize, Deserialize)]
2122pub struct UpdatePullArgs {
2123 pub actor: User,
2124 pub repo: RepoPath,
2125 pub number: u32,
2126 #[serde(default)]
2127 pub assignees: Option<Vec<String>>,
2128 /// May include `g1t`. Asking for its review does not by itself
2129 /// start one; the runner's `review` does. A team is named
2130 /// `workspace/team` (or `@workspace/team`): the whole list, people and
2131 /// teams, replaces who is asked.
2132 #[serde(default)]
2133 pub reviewers: Option<Vec<String>>,
2134 /// Its labels; replaces the whole set, as `set_labels` does.
2135 #[serde(default)]
2136 pub labels: Option<Vec<String>>,
2137 /// The number of the milestone to put it in; 0 takes it out. Needs the
2138 /// Triage role.
2139 #[serde(default)]
2140 pub milestone: Option<u32>,
2141 /// The branch it merges into: an existing branch other than its own.
2142 /// Needs the Write role.
2143 #[serde(default)]
2144 pub base: Option<String>,
2145}
2146
2147/// `report_checks`: what a sandbox says about its run. With no results and
2148/// no error it has started. `skip` forgets the run, for one that will not
2149/// be carried out. Returns `Outcome<CheckRun>`.
2150#[derive(Debug, Serialize, Deserialize)]
2151#[serde(rename_all = "camelCase")]
2152pub struct ReportChecksArgs {
2153 pub run_id: String,
2154 pub token: String,
2155 #[serde(default)]
2156 pub results: Vec<CheckResult>,
2157 #[serde(default)]
2158 pub error: Option<String>,
2159 #[serde(default)]
2160 pub skip: bool,
2161}
2162
2163/// A pull request in progress, with where it lives.
2164#[derive(Debug, Serialize, Deserialize)]
2165pub struct ActivePull {
2166 pub pull: Pull,
2167 pub issue: Option<Issue>,
2168 /// Where it stands, for one g1t is seeing through.
2169 #[serde(default)]
2170 pub lifecycle: Option<Lifecycle>,
2171}
2172
2173/// `list_active_pulls`: drafts and open pull requests the viewer started,
2174/// most recently active first. Returns `Vec<ActivePull>`. Also
2175/// `list_assigned_issues`: open issues assigned to the viewer, most
2176/// recently changed first. Returns `Vec<Issue>`.
2177#[derive(Debug, Serialize, Deserialize)]
2178pub struct ViewerArgs {
2179 pub viewer: Viewer,
2180}
2181
2182/// `append_session`. Returns `Outcome<Appended>`.
2183#[derive(Debug, Serialize, Deserialize)]
2184pub struct AppendSessionArgs {
2185 pub actor: User,
2186 pub repo: RepoPath,
2187 pub number: u32,
2188 pub entries: Vec<NewSessionEntry>,
2189}
2190
2191#[derive(Debug, Serialize, Deserialize)]
2192pub struct Appended {
2193 pub count: u32,
2194}
2195
2196/// Lowercases, trims and de-duplicates labels, dropping empty ones.
2197/// Returns `None` if there are too many or one is too long.
2198pub fn normalize_labels(labels: &[String]) -> Option<Vec<String>> {
2199 let mut normalized: Vec<String> = Vec::new();
2200 for label in labels {
2201 let label = label
2202 .split_whitespace()
2203 .collect::<Vec<_>>()
2204 .join(" ")
2205 .to_lowercase();
2206 if label.is_empty() || normalized.contains(&label) {
2207 continue;
2208 }
2209 if label.chars().count() > MAX_LABEL_CHARS {
2210 return None;
2211 }
2212 normalized.push(label);
2213 }
2214 (normalized.len() <= MAX_LABELS).then_some(normalized)
2215}
2216
2217#[cfg(test)]
2218mod tests {
2219 use super::*;
2220
2221 fn labels(names: &[&str]) -> Vec<String> {
2222 names.iter().map(|name| (*name).to_owned()).collect()
2223 }
2224
2225 #[test]
2226 fn colors_and_due_dates_are_tidied() {
2227 assert_eq!(tidy_color("#A1B2C3").as_deref(), Some("a1b2c3"));
2228 assert_eq!(tidy_color("fc0").as_deref(), Some("ffcc00"));
2229 assert_eq!(tidy_color("red"), None);
2230 assert_eq!(tidy_color("12345"), None);
2231 assert_eq!(label_color_for("bug"), "d73a4a");
2232 assert_eq!(label_color_for("area: web"), label_color_for("area: web"));
2233 assert_eq!(tidy_due_on("2026-10-14").as_deref(), Some("2026-10-14"));
2234 assert_eq!(tidy_due_on("2026-10-14T00:00:00Z").as_deref(), Some("2026-10-14"));
2235 assert_eq!(tidy_due_on("2026-13-01"), None);
2236 assert_eq!(tidy_due_on("soon"), None);
2237 }
2238
2239 #[test]
2240 fn a_milestone_is_as_far_along_as_its_closed_items() {
2241 let mut milestone: Milestone = serde_json::from_value(serde_json::json!({
2242 "number": 1, "title": "Launch", "state": "open", "createdAt": "", "updatedAt": ""
2243 }))
2244 .unwrap();
2245 assert_eq!(milestone.percent(), 0);
2246 milestone.open_items = 3;
2247 milestone.closed_items = 1;
2248 assert_eq!(milestone.percent(), 25);
2249 }
2250
2251 #[test]
2252 fn labels_are_lowercased_trimmed_and_unique() {
2253 assert_eq!(
2254 normalize_labels(&labels(&[" Bug ", "bug", "", "Good First Issue"])),
2255 Some(labels(&["bug", "good first issue"]))
2256 );
2257 }
2258
2259 #[test]
2260 fn too_long_or_too_many_labels_are_refused() {
2261 assert_eq!(normalize_labels(&["x".repeat(51)]), None);
2262 assert!(normalize_labels(&["x".repeat(50)]).is_some());
2263 let many: Vec<String> = (0..21).map(|i| format!("label-{i}")).collect();
2264 assert_eq!(normalize_labels(&many), None);
2265 }
2266}
2267
2268
2269// --- Merge queue ----------------------------------------------------------
2270
2271/// Where a pull request in a merge queue stands.
2272#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
2273#[serde(rename_all = "snake_case")]
2274pub enum QueueState {
2275 /// Waiting for its turn to be tested.
2276 Waiting,
2277 /// Its combined state is being built and checked.
2278 Testing,
2279 /// Its combined state passed; it lands once everything ahead has.
2280 Passed,
2281 /// Its combined state failed, or would not merge. It left the queue.
2282 Failed,
2283 /// On the default branch.
2284 Landed,
2285 /// Taken out of the queue by a person, or closed.
2286 Removed,
2287}
2288
2289impl QueueState {
2290 pub fn as_str(self) -> &'static str {
2291 match self {
2292 QueueState::Waiting => "waiting",
2293 QueueState::Testing => "testing",
2294 QueueState::Passed => "passed",
2295 QueueState::Failed => "failed",
2296 QueueState::Landed => "landed",
2297 QueueState::Removed => "removed",
2298 }
2299 }
2300
2301 /// Still in the queue.
2302 pub fn is_active(self) -> bool {
2303 matches!(
2304 self,
2305 QueueState::Waiting | QueueState::Testing | QueueState::Passed
2306 )
2307 }
2308}
2309
2310/// One pull request's place in a merge queue.
2311#[derive(Clone, Debug, Serialize, Deserialize)]
2312#[serde(rename_all = "camelCase")]
2313pub struct QueueEntry {
2314 pub id: String,
2315 pub number: u32,
2316 pub title: String,
2317 /// Who or what made the pull request, e.g. `g1t`.
2318 pub agent: String,
2319 pub state: QueueState,
2320 /// The pull requests merged ahead of it in the state being tested, in
2321 /// queue order. Empty when it was tested on the default branch alone.
2322 pub ahead: Vec<u32>,
2323 /// The default branch's commit the tested state was built on.
2324 pub base_commit: Option<String>,
2325 /// The tested state: the default branch with everything ahead and this.
2326 pub combined_commit: Option<String>,
2327 /// Why it failed: a merge conflict or what could not be run.
2328 pub error: Option<String>,
2329 /// Commands run against the tested state by queues from before its
2330 /// checks were workflows. Empty since.
2331 pub results: Vec<CheckResult>,
2332 /// Username of whoever merged it into the queue: a person, or `g1t`.
2333 pub enqueued_by: String,
2334 /// RFC 3339.
2335 pub created_at: String,
2336 /// RFC 3339. When it landed or left.
2337 pub finished_at: Option<String>,
2338}
2339
2340/// A repository's merge queue: what is in it, in order, and what recently
2341/// left it.
2342#[derive(Clone, Debug, Serialize, Deserialize)]
2343#[serde(rename_all = "camelCase")]
2344pub struct QueueView {
2345 /// Whether the repository merges through the queue.
2346 pub enabled: bool,
2347 pub active: Vec<QueueEntry>,
2348 /// Newest first.
2349 pub recent: Vec<QueueEntry>,
2350}
2351
2352/// `queue`: a repository's merge queue. Returns `Outcome<QueueView>`.
2353#[derive(Debug, Serialize, Deserialize)]
2354pub struct QueueArgs {
2355 pub repo: RepoPath,
2356 pub viewer: Viewer,
2357}
2358
2359/// `queue_build`: builds the next batch to test for a repository, if
2360/// nothing is being tested now: each entry landed by its method on the
2361/// state of the entries ahead of it, in the repository itself, with its
2362/// `merge_group` workflows started. Returns `Vec<QueueJob>`, which is now
2363/// always empty: no state is left for a sandbox to build.
2364#[derive(Debug, Serialize, Deserialize)]
2365#[serde(rename_all = "camelCase")]
2366pub struct QueueBuildArgs {
2367 pub repo_id: String,
2368}
2369
2370/// One pull request in a state being tested: where its change is.
2371#[derive(Clone, Debug, Serialize, Deserialize)]
2372#[serde(rename_all = "camelCase")]
2373pub struct QueueStackItem {
2374 pub number: u32,
2375 pub title: String,
2376 /// The repository holding the change: its fork, or the repository.
2377 pub source: RepoPath,
2378 /// The branch of `source` holding it.
2379 pub branch: String,
2380 pub commit: String,
2381 /// How it lands: the state is built with its commits made this way.
2382 /// States recorded before methods were merged in by merge commit.
2383 #[serde(default = "merge_method")]
2384 pub method: MergeMethod,
2385 /// For `merge` and `squash`: the commit's message, worked out when
2386 /// the state was first built, so that building it again makes the
2387 /// same commit.
2388 #[serde(default, skip_serializing_if = "Option::is_none")]
2389 pub message: Option<String>,
2390 /// For `squash`: who the commit is by.
2391 #[serde(default, skip_serializing_if = "Option::is_none")]
2392 pub author: Option<User>,
2393}
2394
2395/// One combined state to build and check. The work service builds every
2396/// state in the repository itself now, so `queue_build` returns none.
2397#[derive(Clone, Debug, Serialize, Deserialize)]
2398#[serde(rename_all = "camelCase")]
2399pub struct QueueJob {
2400 pub entry_id: String,
2401 /// Lets the sandbox, and nothing else, report this state's result.
2402 pub token: String,
2403 pub repo: RepoPath,
2404 pub default_branch: String,
2405 /// The default branch's commit to build on.
2406 pub base_commit: String,
2407 /// Where to push the tested state, in the repository itself.
2408 pub branch: String,
2409 /// The pull requests to merge in, in order; the last is the entry.
2410 pub stack: Vec<QueueStackItem>,
2411 /// Commands to run on the built state. Always empty: the state is
2412 /// checked by the `merge_group` workflows run on it, and the default
2413 /// branch's required checks must pass there.
2414 pub checks: Vec<String>,
2415 /// Always empty, as `checks`.
2416 #[serde(default)]
2417 pub contract_checks: Vec<String>,
2418 /// Who the sandbox acts as: a member who can push the tested state.
2419 pub actor: User,
2420}
2421
2422/// `report_queue`: the result of building one combined state. Returns
2423/// `Outcome<QueueState>`.
2424#[derive(Debug, Serialize, Deserialize)]
2425#[serde(rename_all = "camelCase")]
2426pub struct ReportQueueArgs {
2427 pub entry_id: String,
2428 pub token: String,
2429 #[serde(default)]
2430 pub combined_commit: Option<String>,
2431 #[serde(default)]
2432 pub results: Vec<CheckResult>,
2433 /// Set when the state could not be built or checked.
2434 #[serde(default)]
2435 pub error: Option<String>,
2436 /// For a merge conflict: the pull request whose change it collided with.
2437 #[serde(default)]
2438 pub conflict_with: Option<u32>,
2439 /// For a merge conflict: the files that conflicted.
2440 #[serde(default)]
2441 pub conflicts: Vec<String>,
2442}
2443
2444
2445/// `locate_pull`: where a pull request lives, by its id, for a tool that
2446/// knows only the fork it is working in (`g1t.sh/pulls/<id>`). Returns
2447/// `Outcome<LocatedPull>`; not found for anyone who cannot see it.
2448#[derive(Debug, Serialize, Deserialize)]
2449pub struct LocatePullArgs {
2450 pub id: String,
2451 pub viewer: Viewer,
2452}
2453
2454#[derive(Clone, Debug, Serialize, Deserialize)]
2455#[serde(rename_all = "camelCase")]
2456pub struct LocatedPull {
2457 pub repo: RepoPath,
2458 pub number: u32,
2459 pub title: String,
2460 pub status: PullStatus,
2461}
2462
2463// --- A person's work -------------------------------------------------------
2464
2465/// Issues or pull requests, on a person's profile.
2466#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
2467#[serde(rename_all = "lowercase")]
2468pub enum AuthoredKind {
2469 Issue,
2470 Pull,
2471}
2472
2473/// The state filter on a person's work. `Closed` takes in merged pull
2474/// requests too; `Merged` is only those.
2475#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
2476#[serde(rename_all = "lowercase")]
2477pub enum AuthoredState {
2478 Open,
2479 Closed,
2480 Merged,
2481}
2482
2483/// How a person's work is ordered.
2484#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
2485#[serde(rename_all = "lowercase")]
2486pub enum AuthoredSort {
2487 /// Newest first.
2488 #[default]
2489 Created,
2490 /// Most recently changed first.
2491 Updated,
2492 /// Oldest first.
2493 Oldest,
2494}
2495
2496/// The most items one `by_author` page holds.
2497pub const AUTHORED_PAGE: u32 = 25;
2498
2499/// `by_author`: the issues and pull requests a person opened, only on
2500/// repositories `viewer` may read, so a private title never reaches anyone
2501/// who could not open it. Returns `Outcome<Authored>`; not found for an
2502/// account that does not exist.
2503#[derive(Debug, Serialize, Deserialize)]
2504pub struct ByAuthorArgs {
2505 pub username: String,
2506 pub viewer: Viewer,
2507 #[serde(default)]
2508 pub kind: Option<AuthoredKind>,
2509 #[serde(default)]
2510 pub state: Option<AuthoredState>,
2511 /// Only work on this repository: `namespace/name`.
2512 #[serde(default)]
2513 pub repo: Option<String>,
2514 #[serde(default)]
2515 pub sort: AuthoredSort,
2516 /// The `next` of the page before, to read on from there.
2517 #[serde(default)]
2518 pub before: Option<String>,
2519 /// At most [`AUTHORED_PAGE`]; that when absent.
2520 #[serde(default)]
2521 pub limit: Option<u32>,
2522}
2523
2524/// One issue or pull request a person opened.
2525#[derive(Clone, Debug, Serialize, Deserialize)]
2526#[serde(rename_all = "camelCase")]
2527pub struct AuthoredItem {
2528 pub kind: AuthoredKind,
2529 pub repo: RepoPath,
2530 pub number: u32,
2531 pub title: String,
2532 /// Open or closed; a merged pull request is closed.
2533 pub state: State,
2534 /// A pull request's own status.
2535 pub status: Option<PullStatus>,
2536 /// Why an issue was closed.
2537 pub reason: Option<IssueReason>,
2538 pub draft: bool,
2539 pub merged: bool,
2540 /// RFC 3339.
2541 pub created_at: String,
2542 /// RFC 3339.
2543 pub updated_at: String,
2544 /// When a pull request was merged. RFC 3339.
2545 pub merged_at: Option<String>,
2546}
2547
2548/// What a person has done, as far as the viewer may see.
2549#[derive(Clone, Debug, Default, Serialize, Deserialize)]
2550#[serde(rename_all = "camelCase")]
2551pub struct AuthoredCounts {
2552 pub pulls_merged: u32,
2553 pub pulls_open: u32,
2554 pub pulls: u32,
2555 pub issues: u32,
2556 pub issues_open: u32,
2557}
2558
2559/// A repository a person has opened work on, with how much.
2560#[derive(Clone, Debug, Serialize, Deserialize)]
2561pub struct AuthoredRepo {
2562 pub repo: RepoPath,
2563 pub count: u32,
2564}
2565
2566/// A page of a person's work.
2567#[derive(Clone, Debug, Default, Serialize, Deserialize)]
2568#[serde(rename_all = "camelCase")]
2569pub struct Authored {
2570 pub items: Vec<AuthoredItem>,
2571 /// Pass as `before` for the next page; null on the last.
2572 pub next: Option<String>,
2573 /// Over every repository the viewer may read, whatever the filters.
2574 pub counts: AuthoredCounts,
2575 /// Those repositories, most work first.
2576 pub repos: Vec<AuthoredRepo>,
2577}
2578
2579/// How many days `contributions` covers: today and the 364 before it.
2580pub const CONTRIBUTION_DAYS: u64 = 365;
2581
2582/// `contributions`: what a person did each day of the last year (commits
2583/// they pushed to a default branch, issues and pull requests opened,
2584/// reviews given), only on repositories `viewer` may read, for the calendar on their profile. Returns
2585/// `Outcome<Contributions>`; not found for an account that does not exist.
2586#[derive(Debug, Serialize, Deserialize)]
2587pub struct ContributionsArgs {
2588 pub username: String,
2589 pub viewer: Viewer,
2590}
2591
2592/// One day with something on it. Days with nothing are left out.
2593#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
2594pub struct ContributionDay {
2595 /// `YYYY-MM-DD`, UTC.
2596 pub date: String,
2597 /// Everything that day, commits included.
2598 pub count: u32,
2599 /// How many of `count` are commits they pushed (repos' `commit_days`).
2600 #[serde(default)]
2601 pub commits: u32,
2602}
2603
2604/// A person's year, as far as the viewer may see.
2605#[derive(Clone, Debug, Default, Serialize, Deserialize)]
2606pub struct Contributions {
2607 /// Oldest first.
2608 pub days: Vec<ContributionDay>,
2609 pub total: u32,
2610 /// The first day counted, `YYYY-MM-DD`; the last is today.
2611 pub from: String,
2612}
2613
2614#[cfg(test)]
2615mod required_tests {
2616 use super::*;
2617
2618 fn status(context: &str, state: &str) -> CommitStatus {
2619 CommitStatus {
2620 context: context.into(),
2621 state: state.into(),
2622 description: Some(format!("{context} {state}")),
2623 target_url: None,
2624 updated_at: String::new(),
2625 source: None,
2626 check_run_id: None,
2627 }
2628 }
2629
2630 #[test]
2631 fn a_context_names_its_check_and_event() {
2632 assert_eq!(check_name("CI / pull_request"), ("CI", Some("pull_request")));
2633 assert_eq!(check_name("Build and test / merge_group"), ("Build and test", Some("merge_group")));
2634 assert_eq!(check_name("g1t / deploy"), ("g1t / deploy", None));
2635 assert_eq!(check_name("g1t / deploy (docs)"), ("g1t / deploy (docs)", None));
2636 assert_eq!(check_name("lint"), ("lint", None));
2637 }
2638
2639 #[test]
2640 fn required_checks_are_missing_pending_failed_or_passed() {
2641 let required = vec!["CI".to_owned(), "Lint".to_owned(), "g1t / deploy".to_owned(), "Docs".to_owned()];
2642 let statuses = [
2643 status("CI / pull_request", "success"),
2644 status("Lint / pull_request", "pending"),
2645 status("g1t / deploy", "failure"),
2646 ];
2647 let states: Vec<RequiredState> = required_checks(&required, &statuses).into_iter().map(|c| c.state).collect();
2648 assert_eq!(
2649 states,
2650 [RequiredState::Success, RequiredState::Pending, RequiredState::Failure, RequiredState::Expected]
2651 );
2652 }
2653
2654 #[test]
2655 fn any_event_reports_a_check_and_a_failure_wins() {
2656 let required = vec!["ci".to_owned()];
2657 let both = [status("CI / push", "failure"), status("CI / pull_request", "success")];
2658 let check = &required_checks(&required, &both)[0];
2659 assert_eq!(check.state, RequiredState::Failure);
2660 assert_eq!(check.description.as_deref(), Some("CI / push failure"));
2661 let queue = [status("CI / merge_group", "success")];
2662 assert_eq!(required_checks(&required, &queue)[0].state, RequiredState::Success);
2663 }
2664
2665 #[test]
2666 fn a_pull_request_merges_into_its_base_or_the_default_branch() {
2667 let mut pull: Pull = serde_json::from_value(serde_json::json!({
2668 "id": "pr_1", "repoId": "rep_1", "number": 14, "issue": null, "title": "Fix it", "body": null,
2669 "agent": "agent", "runtime": "external", "status": "open", "fork": null, "forkRepoId": null,
2670 "branch": "fix", "headCommit": null, "mergeBase": null, "mergedBy": null, "mergedAt": null,
2671 "supersededBy": null, "checkStatus": null, "author": { "id": "x", "username": "x" },
2672 "createdAt": "", "updatedAt": ""
2673 }))
2674 .unwrap();
2675 assert_eq!(pull.base_branch("main"), "main");
2676 assert!(pull.targets_default("main"));
2677 assert_eq!(pull.comparison(&None).base_branch, None);
2678 pull.base = Some("release".into());
2679 assert_eq!(pull.base_branch("main"), "release");
2680 assert!(!pull.targets_default("main"));
2681 assert_eq!(pull.comparison(&None).base_branch.as_deref(), Some("release"));
2682 }
2683
2684 #[test]
2685 fn required_names_are_tidied() {
2686 let names = vec![" CI ".to_owned(), "ci".to_owned(), String::new(), "Lint".to_owned()];
2687 assert_eq!(tidy_required(&names), ["CI", "Lint"]);
2688 }
2689
2690 #[test]
2691 fn a_definition_of_done_is_added_once() {
2692 let items = commands_pass(&["cargo test".to_owned(), " ".to_owned()]);
2693 assert_eq!(items, ["`cargo test` passes."]);
2694 let body = with_definition_of_done("Fix the greeting.", &items);
2695 assert_eq!(body, "Fix the greeting.\n\n## Definition of done\n\n- `cargo test` passes.");
2696 assert_eq!(with_definition_of_done(&body, &items), body);
2697 assert_eq!(with_definition_of_done("", &items), "## Definition of done\n\n- `cargo test` passes.");
2698 assert_eq!(with_definition_of_done(" Text ", &[]), "Text");
2699 }
2700}
2701
2702#[cfg(test)]
2703mod authorship_tests {
2704 use super::*;
2705 use crate::PrincipalKind;
2706 use crate::credentials::{Acting, Principal};
2707 use crate::identity::{AGENT_ID, AgentScope};
2708
2709 fn person() -> User {
2710 User {
2711 id: "usr_1".into(),
2712 username: "syntaqx".into(),
2713 verified: true,
2714 ..User::default()
2715 }
2716 }
2717
2718 fn agent_for(id: &str, username: &str) -> User {
2719 User {
2720 id: AGENT_ID.into(),
2721 username: "g1t".into(),
2722 kind: PrincipalKind::Agent,
2723 acting: Some(Box::new(Acting {
2724 credential_id: "tok_1".into(),
2725 agent: "g1t".into(),
2726 on_behalf_of: Principal { id: id.into(), username: username.into() },
2727 scope: AgentScope {
2728 repo: RepoPath { namespace: "acme".into(), name: "web".into() },
2729 operations: Vec::new(),
2730 run: None,
2731 },
2732 })),
2733 ..User::default()
2734 }
2735 }
2736
2737 fn pull(author: User, requested_by: Option<User>) -> Pull {
2738 let mut pull: Pull = serde_json::from_value(serde_json::json!({
2739 "id": "pr_1", "repoId": "rep_1", "number": 14, "issue": 12, "title": "Fix it", "body": null,
2740 "agent": "g1t", "runtime": "hosted", "status": "open", "fork": null, "forkRepoId": null,
2741 "branch": null, "headCommit": null, "mergeBase": null, "mergedBy": null, "mergedAt": null,
2742 "supersededBy": null, "checkStatus": null,
2743 "author": { "id": "x", "username": "x" },
2744 "createdAt": "", "updatedAt": ""
2745 }))
2746 .unwrap();
2747 pull.author = author;
2748 pull.requested_by = requested_by;
2749 pull
2750 }
2751
2752 #[test]
2753 fn a_pull_request_knows_where_its_change_is() {
2754 let repo = RepoPath { namespace: "flagon".into(), name: "g1t".into() };
2755 let viewer: Viewer = None;
2756 // A branch of the repository itself.
2757 let mut branch = pull(User::default(), None);
2758 branch.branch = Some("fix".into());
2759 assert_eq!((branch.source_id(), branch.source_path(&repo)), ("rep_1", repo.clone()));
2760 assert!(!branch.from_other_repo() && branch.target_id().is_none());
2761 let compared = branch.comparison(&viewer);
2762 assert_eq!((compared.repo_id.as_str(), compared.head.as_deref()), ("rep_1", Some("fix")));
2763 assert!(compared.target_id.is_none());
2764
2765 // A branch of someone's fork: compared by name in the fork, from
2766 // the repository it merges into.
2767 let fork = RepoPath { namespace: "ana".into(), name: "g1t".into() };
2768 let mut forked = branch.clone();
2769 forked.head_repo = Some(fork.clone());
2770 forked.head_repo_id = Some("rep_2".into());
2771 assert_eq!((forked.source_id(), forked.source_path(&repo)), ("rep_2", fork));
2772 assert!(forked.from_other_repo() && forked.from_head_repo());
2773 assert_eq!(forked.target_id().as_deref(), Some("rep_1"));
2774 let compared = forked.comparison(&viewer);
2775 assert_eq!((compared.repo_id.as_str(), compared.head.as_deref()), ("rep_2", Some("fix")));
2776 assert_eq!(compared.target_id.as_deref(), Some("rep_1"));
2777 // Settled, by the commit it was closed at.
2778 forked.status = PullStatus::Closed;
2779 forked.head_commit = Some("abc".into());
2780 assert_eq!(forked.comparison(&viewer).head.as_deref(), Some("abc"));
2781
2782 // A working copy: compared as a whole, and knows its own target.
2783 let mut copy = pull(User::default(), None);
2784 copy.fork = Some(RepoPath { namespace: "pulls".into(), name: "pr_1".into() });
2785 copy.fork_repo_id = Some("rep_3".into());
2786 assert_eq!(copy.source_id(), "rep_3");
2787 assert!(copy.from_other_repo() && !copy.from_head_repo() && copy.target_id().is_none());
2788 let compared = copy.comparison(&viewer);
2789 assert_eq!((compared.repo_id.as_str(), compared.head), ("rep_3", None));
2790 }
2791
2792 #[test]
2793 fn a_pull_request_from_before_forks_reads_without_a_head_repo() {
2794 let read = pull(User::default(), None);
2795 assert!(read.head_repo.is_none() && read.head_repo_id.is_none());
2796 let json = serde_json::to_value(&read).unwrap();
2797 assert!(json.get("headRepo").is_some() && json.get("headRepoId").is_some());
2798 }
2799
2800 #[test]
2801 fn what_g1t_s_agent_files_at_work_is_g1t_s_requested_by_whoever_it_works_for() {
2802 let (author, asked) = authorship(&agent_for("usr_1", "syntaqx"));
2803 assert_eq!(author.id, AGENT_ID);
2804 assert_eq!(asked.map(|user| user.username), Some("syntaqx".into()));
2805 }
2806
2807 #[test]
2808 fn nobody_asked_for_g1t_s_own_work() {
2809 // A run g1t started itself acts for g1t, not for a person.
2810 let (author, asked) = authorship(&agent_for(crate::system::ID, "g1t"));
2811 assert_eq!(author.id, AGENT_ID);
2812 assert!(asked.is_none());
2813 // A security update g1t opens is g1t's own, as before.
2814 let (author, asked) = authorship(&User::system("acme"));
2815 assert_eq!((author.id.as_str(), author.kind), (crate::system::ID, PrincipalKind::System));
2816 assert!(asked.is_none());
2817 }
2818
2819 #[test]
2820 fn anyone_else_opens_their_own() {
2821 let (author, asked) = authorship(&person());
2822 assert_eq!((author.id.as_str(), author.kind), ("usr_1", PrincipalKind::User));
2823 assert!(asked.is_none());
2824 // Nothing but who they are is kept.
2825 assert!(author.workspaces.is_empty() && author.acting.is_none());
2826 }
2827
2828 #[test]
2829 fn the_requester_owns_g1t_s_pull_request_and_an_author_their_own() {
2830 let made = pull(g1t_author(), Some(person()));
2831 assert_eq!(made.owner().id, "usr_1");
2832 assert!(made.is_owned_by("usr_1"));
2833 assert!(!made.is_owned_by(AGENT_ID), "g1t's agent does not answer for its own change");
2834 let own = pull(person(), None);
2835 assert_eq!(own.owner().id, "usr_1");
2836 assert!(own.is_owned_by("usr_1"));
2837 }
2838
2839 #[test]
2840 fn requested_by_is_null_when_nobody_asked_and_read_as_absent_from_older_senders() {
2841 let own = pull(person(), None);
2842 let sent = serde_json::to_value(&own).unwrap();
2843 assert!(sent["requestedBy"].is_null());
2844 let mut older = sent.clone();
2845 older.as_object_mut().unwrap().remove("requestedBy");
2846 let read: Pull = serde_json::from_value(older).unwrap();
2847 assert!(read.requested_by.is_none());
2848 }
2849
2850 #[test]
2851 fn a_comment_is_changed_by_its_author_or_a_maintainer() {
2852 use CommentKind::{Comment, Event};
2853 // Its author edits and deletes it; someone else needs Maintain.
2854 assert!(may_change_comment(Comment, false, "usr_1", "usr_1", false, false).is_ok());
2855 assert!(may_change_comment(Comment, false, "usr_1", "usr_1", false, true).is_ok());
2856 assert!(may_change_comment(Comment, false, "usr_1", "usr_2", false, false).is_err());
2857 assert!(may_change_comment(Comment, false, "usr_1", "usr_2", false, true).is_err());
2858 assert!(may_change_comment(Comment, false, "usr_1", "usr_2", true, false).is_ok());
2859 assert!(may_change_comment(Comment, false, "usr_1", "usr_2", true, true).is_ok());
2860 // A review's text can be edited, but its verdict stays.
2861 assert!(may_change_comment(Comment, true, "usr_1", "usr_1", false, false).is_ok());
2862 assert!(may_change_comment(Comment, true, "usr_1", "usr_1", true, true).is_err());
2863 // A note of what happened is nobody's to change.
2864 assert!(may_change_comment(Event, false, "usr_1", "usr_1", true, false).is_err());
2865 assert!(may_change_comment(Event, false, "usr_1", "usr_1", true, true).is_err());
2866 }
2867}