use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Section {
#[default]
PullRequests,
WorkItems,
Pipelines,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ProviderType {
Demo,
GitHub,
AzureDevOps,
Linear,
GitLab,
Bitbucket,
Jira,
}
impl ProviderType {
pub fn as_str(&self) -> &'static str {
match self {
ProviderType::Demo => "Demo",
ProviderType::GitHub => "GitHub",
ProviderType::AzureDevOps => "AzureDevOps",
ProviderType::Linear => "Linear",
ProviderType::GitLab => "GitLab",
ProviderType::Bitbucket => "Bitbucket",
ProviderType::Jira => "Jira",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PullRequestStatus {
Open,
Draft,
Merged,
Closed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReviewVote {
Rejected,
WaitingForAuthor,
NoVote,
ApprovedWithSuggestions,
Approved,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum WorkItemStateCategory {
Triage,
Backlog,
Unstarted,
Started,
Review,
Completed,
Canceled,
}
impl WorkItemStateCategory {
pub fn is_active(self) -> bool {
matches!(self, WorkItemStateCategory::Started | WorkItemStateCategory::Review)
}
}
pub fn state_name_is_review(name: &str) -> bool {
const WORDS: [&str; 12] =
["review", "reviewing", "qa", "test", "testing", "verify", "verifying", "verification", "uat", "approval", "resolved", "validation"];
name.split(|c: char| !c.is_alphanumeric()).any(|word| WORDS.iter().any(|w| word.eq_ignore_ascii_case(w)))
}
#[cfg(test)]
mod state_tests {
use super::*;
#[test]
fn a_review_state_is_told_by_whole_words_in_its_name() {
for name in ["In Review", "Code review", "Ready for QA", "Testing", "In test", "Awaiting approval", "UAT", "Resolved", "to-verify"] {
assert!(state_name_is_review(name), "{name}");
}
for name in ["In Progress", "Doing", "Latest", "Preview build", "Contested", "Ready for dev", "Blocked", ""] {
assert!(!state_name_is_review(name), "{name}");
}
}
#[test]
fn in_review_is_still_in_flight_and_reads_back_from_json() {
assert!(WorkItemStateCategory::Started.is_active() && WorkItemStateCategory::Review.is_active());
assert!(!WorkItemStateCategory::Unstarted.is_active() && !WorkItemStateCategory::Completed.is_active());
let json = serde_json::to_string(&WorkItemStateCategory::Review).unwrap();
assert_eq!(json, "\"Review\"");
assert_eq!(serde_json::from_str::<WorkItemStateCategory>(&json).unwrap(), WorkItemStateCategory::Review);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PipelineRunStatus {
Queued,
Running,
Waiting,
Succeeded,
PartiallySucceeded,
Failed,
Canceled,
Skipped,
}
impl PipelineRunStatus {
pub fn is_active(self) -> bool {
matches!(self, Self::Queued | Self::Running | Self::Waiting)
}
pub fn with_pending_approval(self, pending: bool) -> Self {
if pending && self.is_active() {
Self::Waiting
} else {
self
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum CheckStatus {
#[default]
None,
Pending,
Passed,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum MergeableState {
#[default]
Unknown,
Mergeable,
Blocked,
Conflicting,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum FileChangeKind {
Added,
Modified,
Deleted,
Renamed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: String,
pub display_name: String,
pub handle: Option<String>,
pub avatar_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Repository {
pub id: String,
pub name: String,
pub full_name: Option<String>,
pub default_branch: Option<String>,
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comment {
pub id: String,
pub author: User,
pub body: String,
pub created_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommentThread {
pub id: String,
pub comments: Vec<Comment>,
pub file_path: Option<String>,
pub line: Option<i64>,
pub is_resolved: bool,
#[serde(default = "resolvable_by_default")]
pub is_resolvable: bool,
}
fn resolvable_by_default() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TimelineEventKind {
Approved,
ChangesRequested,
Reviewed,
Commented,
Merged,
Closed,
Reopened,
StateChanged,
Assigned,
Labeled,
Committed,
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimelineEvent {
pub actor: Option<User>,
pub kind: TimelineEventKind,
pub summary: String,
pub at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Reviewer {
pub user: User,
pub vote: ReviewVote,
pub is_required: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CheckSummary {
pub successful: u32,
pub in_progress: u32,
pub failed: u32,
pub neutral: u32,
}
impl CheckSummary {
pub fn total(&self) -> u32 {
self.successful + self.in_progress + self.failed + self.neutral
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckRun {
pub name: String,
pub status: CheckStatus,
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Commit {
pub sha: String,
pub message: String,
pub author: String,
pub date: Option<DateTime<Utc>>,
pub url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DiffSide {
Old,
New,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineComment {
pub path: String,
pub line: i64,
pub side: DiffSide,
pub body: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileChange {
pub path: String,
pub kind: FileChangeKind,
pub additions: i64,
pub deletions: i64,
pub patch: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PullRequest {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
pub number: Option<i64>,
pub title: String,
pub description: Option<String>,
pub author: User,
pub status: PullRequestStatus,
pub is_draft: bool,
pub source_ref: Option<String>,
pub target_ref: Option<String>,
pub reviewers: Vec<Reviewer>,
pub labels: Vec<String>,
pub checks: CheckStatus,
pub check_summary: Option<CheckSummary>,
pub mergeable: MergeableState,
pub changed_files: i64,
pub additions: i64,
pub deletions: i64,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
pub url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkItemParent {
pub kind: String,
pub id: String,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkItem {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
pub identifier: Option<String>,
pub title: String,
pub description: Option<String>,
pub state: String,
pub state_category: WorkItemStateCategory,
pub work_item_type: Option<String>,
pub assignee: Option<User>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<WorkItemParent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineDefinition {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
pub name: String,
pub path: Option<String>,
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineStep {
pub name: String,
pub status: PipelineRunStatus,
pub started_at: Option<DateTime<Utc>>,
pub finished_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineJob {
pub id: String,
pub name: String,
pub status: PipelineRunStatus,
pub started_at: Option<DateTime<Utc>>,
pub finished_at: Option<DateTime<Utc>>,
pub steps: Vec<PipelineStep>,
pub url: Option<String>,
pub problem: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineStage {
pub name: String,
pub status: PipelineRunStatus,
pub jobs: Vec<PipelineJob>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineRun {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
pub definition_id: String,
pub number: Option<i64>,
pub name: Option<String>,
pub title: Option<String>,
pub status: PipelineRunStatus,
pub triggered_by: Option<User>,
pub branch: Option<String>,
pub commit_sha: Option<String>,
pub started_at: Option<DateTime<Utc>>,
pub finished_at: Option<DateTime<Utc>>,
pub url: Option<String>,
pub stages: Vec<PipelineStage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub event: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attempt: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pull_request: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum AnnotationLevel {
Failure,
Warning,
Notice,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PipelineAnnotation {
pub level: AnnotationLevel,
pub message: String,
pub title: Option<String>,
pub path: Option<String>,
pub line: Option<u32>,
pub job_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PipelineArtifact {
pub id: String,
pub name: String,
pub size_bytes: Option<u64>,
pub expires_at: Option<DateTime<Utc>>,
pub url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PipelineApproval {
pub id: String,
pub name: String,
pub can_respond: bool,
#[serde(default = "holds_by_default")]
pub blocks_run: bool,
}
fn holds_by_default() -> bool {
true
}
impl PullRequest {
pub fn item_ref(&self) -> crate::provider::ItemRef {
crate::provider::ItemRef::maybe(self.repository.clone(), self.id.clone())
}
}
impl WorkItem {
pub fn item_ref(&self) -> crate::provider::ItemRef {
crate::provider::ItemRef::maybe(self.repository.clone(), self.id.clone())
}
}
impl PipelineRun {
pub fn item_ref(&self) -> crate::provider::ItemRef {
crate::provider::ItemRef::maybe(self.repository.clone(), self.id.clone())
}
pub fn definition_ref(&self) -> crate::provider::ItemRef {
crate::provider::ItemRef::maybe(self.repository.clone(), self.definition_id.clone())
}
pub fn has_running_work(&self) -> bool {
let running = PipelineRunStatus::Running;
self.stages.iter().any(|s| {
s.status == running || s.jobs.iter().any(|j| j.status == running || j.steps.iter().any(|t| t.status == running))
})
}
pub fn shown_status(&self, blocking_gate: bool) -> PipelineRunStatus {
let held = blocking_gate || self.stages.iter().any(|s| s.status == PipelineRunStatus::Waiting);
self.status.with_pending_approval(held && !self.has_running_work())
}
}
impl PipelineDefinition {
pub fn item_ref(&self) -> crate::provider::ItemRef {
crate::provider::ItemRef::maybe(self.repository.clone(), self.id.clone())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ApprovalDecision {
Approve,
Reject,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NotificationKind {
ReviewRequested,
Mention,
Assigned,
CiFailed,
Comment,
StateChange,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NotificationItemType {
PullRequest,
WorkItem,
Pipeline,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Notification {
pub id: String,
pub kind: NotificationKind,
pub item_type: NotificationItemType,
pub item_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
pub title: String,
pub context: String,
pub url: Option<String>,
pub unread: bool,
pub updated_at: Option<DateTime<Utc>>,
}
#[cfg(test)]
mod pipeline_status_tests {
use super::PipelineRunStatus::{self, *};
#[test]
fn waiting_is_in_flight_and_skipped_is_not() {
for s in [Queued, Running, Waiting] {
assert!(s.is_active(), "{s:?}");
}
for s in [Succeeded, PartiallySucceeded, Failed, Canceled, Skipped] {
assert!(!s.is_active(), "{s:?}");
}
}
#[test]
fn a_pending_gate_holds_only_an_active_run() {
assert_eq!(Running.with_pending_approval(true), Waiting);
assert_eq!(Queued.with_pending_approval(true), Waiting);
assert_eq!(Running.with_pending_approval(false), Running);
assert_eq!(Succeeded.with_pending_approval(true), Succeeded);
assert_eq!(Failed.with_pending_approval(true), Failed);
}
fn run_with(status: PipelineRunStatus, stages: Vec<super::PipelineStage>) -> super::PipelineRun {
super::PipelineRun {
event: None, attempt: None, pull_request: None, repository: None, id: "1".into(), definition_id: "d".into(),
number: None, name: None, title: None, status, triggered_by: None, branch: None, commit_sha: None,
started_at: None, finished_at: None, url: None, stages,
}
}
fn stage(status: PipelineRunStatus) -> super::PipelineStage {
super::PipelineStage { name: "s".into(), status, jobs: vec![] }
}
#[test]
fn a_run_reads_waiting_only_when_a_gate_holds_it_and_nothing_runs() {
assert_eq!(run_with(Running, vec![]).shown_status(true), Waiting);
assert_eq!(run_with(Running, vec![]).shown_status(false), Running);
assert_eq!(run_with(Running, vec![stage(Succeeded), stage(Running), stage(Queued)]).shown_status(true), Running);
assert_eq!(run_with(Running, vec![stage(Succeeded), stage(Waiting)]).shown_status(false), Waiting);
assert_eq!(run_with(Canceled, vec![stage(Waiting)]).shown_status(true), Canceled);
}
#[test]
fn an_approval_from_an_older_cache_still_holds_its_run() {
let a: super::PipelineApproval = serde_json::from_str(r#"{ "id": "g", "name": "Prod", "can_respond": true }"#).unwrap();
assert!(a.blocks_run);
}
#[test]
fn new_states_round_trip_by_name() {
for s in [Waiting, Skipped] {
let json = serde_json::to_string(&s).unwrap();
assert_eq!(serde_json::from_str::<PipelineRunStatus>(&json).unwrap(), s);
}
assert_eq!(serde_json::to_string(&Waiting).unwrap(), "\"Waiting\"");
}
}