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