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