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