use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{ModelError, Repository, Result};
platform_number!(ChangeRequestNumber);
platform_number!(ReviewLine);
const BRANCH_REF_PREFIX: &str = "refs/heads/";
const FORBIDDEN_BRANCH_CHARACTERS: [char; 7] = ['~', '^', ':', '?', '*', '[', '\\'];
#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
pub enum InvalidHeadRef {
#[error("head ref must be fully qualified as refs/heads/<branch>")]
NotABranchRef,
#[error("head ref names no branch")]
NoBranch,
#[error("branch name is one git refuses to create")]
InvalidBranchName,
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(try_from = "SerializedHead", into = "SerializedHead")]
pub struct ChangeRequestHead {
repository: Repository,
branch: String,
}
#[derive(Clone, Deserialize, Serialize)]
struct SerializedHead {
repository: Repository,
head_ref: String,
}
impl TryFrom<SerializedHead> for ChangeRequestHead {
type Error = InvalidHeadRef;
fn try_from(value: SerializedHead) -> std::result::Result<Self, Self::Error> {
Self::new(value.repository, &value.head_ref)
}
}
impl From<ChangeRequestHead> for SerializedHead {
fn from(value: ChangeRequestHead) -> Self {
Self {
head_ref: format!("{BRANCH_REF_PREFIX}{}", value.branch),
repository: value.repository,
}
}
}
impl ChangeRequestHead {
pub fn new(
repository: Repository,
head_ref: &str,
) -> std::result::Result<Self, InvalidHeadRef> {
Ok(Self {
repository,
branch: head_branch(head_ref)?.to_owned(),
})
}
#[must_use]
pub fn repository(&self) -> &Repository {
&self.repository
}
#[must_use]
pub fn branch(&self) -> &str {
&self.branch
}
}
fn head_branch(head_ref: &str) -> std::result::Result<&str, InvalidHeadRef> {
let branch = head_ref
.strip_prefix(BRANCH_REF_PREFIX)
.ok_or(InvalidHeadRef::NotABranchRef)?;
if branch.is_empty() {
return Err(InvalidHeadRef::NoBranch);
}
if creatable_branch_name(branch) {
Ok(branch)
} else {
Err(InvalidHeadRef::InvalidBranchName)
}
}
fn creatable_branch_name(branch: &str) -> bool {
if branch == "@"
|| branch == "HEAD"
|| branch.starts_with('-')
|| branch.ends_with('.')
|| branch.contains("..")
|| branch.contains("@{")
{
return false;
}
if branch.chars().any(|character| {
character.is_ascii_control()
|| character == ' '
|| FORBIDDEN_BRANCH_CHARACTERS.contains(&character)
}) {
return false;
}
branch.split('/').all(|component| {
!component.is_empty() && !component.starts_with('.') && !component.ends_with(".lock")
})
}
macro_rules! opaque_review_id {
($name:ident, $field:literal, $entity:literal) => {
#[doc = concat!("Opaque provider identifier for a ", $entity, ".")]
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct $name(String);
impl $name {
pub fn new(value: impl Into<String>) -> std::result::Result<Self, crate::ModelError> {
let value = value.into();
if value.is_empty() {
return Err(crate::ModelError::Empty { field: $field });
}
Ok(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
};
}
opaque_review_id!(ReviewId, "review id", "review");
opaque_review_id!(ReviewThreadId, "review thread id", "review thread");
opaque_review_id!(ReviewCommentId, "review comment id", "review comment");
opaque_review_id!(ReviewRequestId, "review request id", "review request");
opaque_review_id!(ReviewActorId, "review actor id", "review actor");
opaque_review_id!(ReviewTeamId, "review team id", "review team");
opaque_review_id!(ProviderAppId, "provider app id", "provider application");
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub struct CommitRange {
pub base_sha: String,
pub head_sha: String,
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub struct ReviewedRevision {
pub head_sha: String,
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewActorKind {
User,
Bot,
Placeholder,
Organization,
EnterpriseUser,
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub struct ReviewActor {
pub id: ReviewActorId,
pub login: String,
pub kind: ReviewActorKind,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewTeamKind {
Organization,
Enterprise,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ReviewTeam {
pub id: ReviewTeamId,
pub slug: String,
pub name: String,
pub kind: ReviewTeamKind,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewTarget {
Actor(ReviewActor),
Team(ReviewTeam),
Unavailable,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewRequestTarget {
User(String),
Bot(String),
Team(String),
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ReviewRequest {
pub id: ReviewRequestId,
pub target: ReviewTarget,
pub request_target: Option<ReviewRequestTarget>,
pub requested_at: Option<DateTime<Utc>>,
pub as_code_owner: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ProviderApp {
pub id: ProviderAppId,
pub slug: String,
pub name: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewDisposition {
Approved,
ChangesRequested,
Commented,
Dismissed,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewRelationship {
ChangeAuthor,
Other,
Unknown,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewAuthor {
ChangeAuthor,
Other(ReviewActor),
Unknown(ReviewActor),
}
impl ReviewAuthor {
#[must_use]
pub const fn relationship(&self) -> ReviewRelationship {
match self {
Self::ChangeAuthor => ReviewRelationship::ChangeAuthor,
Self::Other(_) => ReviewRelationship::Other,
Self::Unknown(_) => ReviewRelationship::Unknown,
}
}
#[must_use]
pub fn actor<'a>(&'a self, change_author: &'a ReviewActor) -> &'a ReviewActor {
match self {
Self::ChangeAuthor => change_author,
Self::Other(actor) | Self::Unknown(actor) => actor,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewState {
Draft,
Submitted {
disposition: ReviewDisposition,
submitted_at: DateTime<Utc>,
},
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewThreadStatus {
Open,
Resolved,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FindingSeverity {
Critical,
Major,
Minor,
Nit,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum FindingResolutionReason {
Addressed,
Invalid,
WontFix,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct FindingResolution {
pub reason: FindingResolutionReason,
pub addressing_severity: FindingSeverity,
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(try_from = "String", into = "String")]
pub struct FindingResolutionReply(String);
impl FindingResolutionReply {
pub fn new(value: impl Into<String>) -> std::result::Result<Self, ModelError> {
let value = value.into();
if value.trim().is_empty() {
return Err(ModelError::Empty {
field: "finding resolution reply",
});
}
Ok(Self(value))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for FindingResolutionReply {
type Error = ModelError;
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<FindingResolutionReply> for String {
fn from(value: FindingResolutionReply) -> Self {
value.0
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case", tag = "compatibility")]
pub enum FindingResolutionRecord {
Supported {
resolution: FindingResolution,
source_reply_id: ReviewCommentId,
},
Unsupported {
metadata_format: String,
source_reply_id: ReviewCommentId,
},
}
impl FindingResolutionRecord {
#[must_use]
pub fn supported_resolution(&self) -> Option<FindingResolution> {
match self {
Self::Supported { resolution, .. } => Some(*resolution),
Self::Unsupported { .. } => None,
}
}
#[must_use]
pub fn source_reply_id(&self) -> &ReviewCommentId {
match self {
Self::Supported {
source_reply_id, ..
}
| Self::Unsupported {
source_reply_id, ..
} => source_reply_id,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ReviewComment {
pub id: ReviewCommentId,
pub author: ReviewActor,
pub body: String,
pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ReviewLineRange {
pub start: Option<ReviewLine>,
pub end: ReviewLine,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewDiffSide {
Left,
Right,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewAnchor {
File,
Lines {
side: ReviewDiffSide,
original: ReviewLineRange,
current: Option<ReviewLineRange>,
},
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ReviewLocation {
pub path: String,
pub anchor: ReviewAnchor,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ReviewThread {
pub id: ReviewThreadId,
pub location: ReviewLocation,
pub outdated: bool,
pub status: ReviewThreadStatus,
pub comment: ReviewComment,
pub replies: Vec<ReviewComment>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ReviewFinding {
#[serde(flatten)]
pub thread: ReviewThread,
pub resolution: Option<FindingResolutionRecord>,
}
impl std::ops::Deref for ReviewFinding {
type Target = ReviewThread;
fn deref(&self) -> &Self::Target {
&self.thread
}
}
impl std::ops::DerefMut for ReviewFinding {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.thread
}
}
impl ReviewFinding {
#[must_use]
pub fn resolution_reply(&self) -> Option<&ReviewComment> {
let reply_id = self.resolution.as_ref()?.source_reply_id();
self.replies.iter().find(|reply| &reply.id == reply_id)
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Review {
pub id: ReviewId,
pub author: ReviewAuthor,
pub via_app: Option<ProviderApp>,
pub revision: ReviewedRevision,
pub state: ReviewState,
pub summary: Option<String>,
pub findings: Vec<ReviewFinding>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangeRequestState {
Open,
Closed,
Merged { merged_at: DateTime<Utc> },
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Mergeability {
Mergeable,
Conflicted,
Unknown,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct ChangeRequest {
pub number: ChangeRequestNumber,
pub title: String,
pub state: ChangeRequestState,
pub draft: bool,
pub commit_range: CommitRange,
pub base_branch: String,
pub head: Option<ChangeRequestHead>,
pub mergeability: Mergeability,
pub author: ReviewActor,
pub updated_at: DateTime<Utc>,
pub reviews: Vec<Review>,
pub standalone_threads: Vec<ReviewThread>,
pub unanchored_comments: Vec<ReviewComment>,
pub outstanding_requests: Vec<ReviewRequest>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckConclusion {
Success,
Failure,
Neutral,
Cancelled,
TimedOut,
ActionRequired,
Skipped,
Stale,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PublishedCheckConclusion {
Success,
Failure,
Neutral,
Cancelled,
TimedOut,
ActionRequired,
Skipped,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckStatus {
Requested,
Queued,
Pending,
Waiting,
InProgress,
Completed {
conclusion: CheckConclusion,
completed_at: DateTime<Utc>,
},
}
impl CheckStatus {
#[must_use]
pub const fn conclusion(&self) -> Option<CheckConclusion> {
match self {
Self::Completed { conclusion, .. } => Some(*conclusion),
Self::Requested | Self::Queued | Self::Pending | Self::Waiting | Self::InProgress => {
None
}
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct CheckRun {
pub name: String,
pub head_sha: String,
pub via_app: Option<ProviderApp>,
pub status: CheckStatus,
pub summary: Option<String>,
pub html_url: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct CheckOutcome {
pub name: String,
pub head_sha: String,
pub conclusion: PublishedCheckConclusion,
pub summary: String,
}
#[async_trait]
pub trait CodeReviewsProvider: Send + Sync {
async fn change_request(
&self,
repository: &Repository,
number: ChangeRequestNumber,
) -> Result<ChangeRequest>;
async fn open_change_requests(
&self,
repository: &Repository,
head: &ChangeRequestHead,
) -> Result<Vec<ChangeRequestNumber>>;
async fn resolve_thread(
&self,
repository: &Repository,
number: ChangeRequestNumber,
thread_id: &ReviewThreadId,
) -> Result<()>;
async fn resolve_finding(
&self,
repository: &Repository,
number: ChangeRequestNumber,
thread_id: &ReviewThreadId,
resolution: FindingResolution,
reply: &FindingResolutionReply,
) -> Result<()>;
async fn request_reviewers(
&self,
repository: &Repository,
number: ChangeRequestNumber,
reviewers: &[ReviewRequestTarget],
) -> Result<()>;
async fn mark_ready(&self, repository: &Repository, number: ChangeRequestNumber) -> Result<()>;
async fn checks(&self, repository: &Repository, head_sha: &str) -> Result<Vec<CheckRun>>;
async fn publish_check(
&self,
repository: &Repository,
app_name: &str,
outcome: &CheckOutcome,
) -> Result<()>;
}
#[cfg(test)]
mod tests {
use chrono::TimeZone;
use super::*;
fn actor(login: &str) -> ReviewActor {
ReviewActor {
id: ReviewActorId::new(format!("actor-{login}")).expect("actor id"),
login: login.to_owned(),
kind: ReviewActorKind::Bot,
}
}
fn comment(id: &str, author: ReviewActor) -> ReviewComment {
ReviewComment {
id: ReviewCommentId::new(id).expect("comment id"),
author,
body: "comment".to_owned(),
created_at: Utc.timestamp_opt(1, 0).single().expect("timestamp"),
updated_at: Some(Utc.timestamp_opt(1, 0).single().expect("timestamp")),
}
}
fn thread(id: &str, author: ReviewActor) -> ReviewThread {
ReviewThread {
id: ReviewThreadId::new(id).expect("thread id"),
location: ReviewLocation {
path: "src/lib.rs".to_owned(),
anchor: ReviewAnchor::Lines {
side: ReviewDiffSide::Right,
original: ReviewLineRange {
start: None,
end: ReviewLine::new(10).expect("line"),
},
current: Some(ReviewLineRange {
start: None,
end: ReviewLine::new(10).expect("line"),
}),
},
},
outdated: false,
status: ReviewThreadStatus::Open,
comment: comment(&format!("comment-{id}"), author),
replies: Vec::new(),
}
}
fn review(id: &str, author: ReviewActor, findings: Vec<ReviewFinding>) -> Review {
Review {
id: ReviewId::new(id).expect("review id"),
author: ReviewAuthor::Other(author),
via_app: None,
revision: ReviewedRevision {
head_sha: "head".to_owned(),
},
state: ReviewState::Submitted {
disposition: ReviewDisposition::Commented,
submitted_at: Utc.timestamp_opt(1, 0).single().expect("timestamp"),
},
summary: None,
findings,
}
}
fn sandbox() -> Repository {
Repository::new("civitas-forge", "sandbox").expect("repository")
}
#[test]
fn head_reads_one_ref_spelling_so_every_branch_stays_addressable() {
for (head_ref, branch) in [
("refs/heads/main", "main"),
("refs/heads/feat/open-request", "feat/open-request"),
("refs/heads/refs/heads/main", "refs/heads/main"),
] {
let head = ChangeRequestHead::new(sandbox(), head_ref).expect("branch ref");
assert_eq!(head.branch(), branch);
assert_eq!(head.repository(), &sandbox());
}
}
#[test]
fn head_states_why_a_string_names_no_branch() {
for unqualified in ["", "main", "refs/tags/v1.1.0", "refs/remotes/origin/main"] {
assert_eq!(
ChangeRequestHead::new(sandbox(), unqualified),
Err(InvalidHeadRef::NotABranchRef),
"{unqualified:?}"
);
}
assert_eq!(
ChangeRequestHead::new(sandbox(), "refs/heads/"),
Err(InvalidHeadRef::NoBranch)
);
for uncreatable in [
"refs/heads/@",
"refs/heads/HEAD",
"refs/heads/-topic",
"refs/heads/main.",
"refs/heads/ma..in",
"refs/heads/ma@{in",
"refs/heads/ma:in",
"refs/heads/ma in",
"refs/heads/main\n",
"refs/heads/ma~in",
"refs/heads/ma[in",
"refs/heads/ma\\in",
"refs/heads/feat//open",
"refs/heads/feat/",
"refs/heads//feat",
"refs/heads/feat/.hidden",
"refs/heads/feat/open.lock",
] {
assert_eq!(
ChangeRequestHead::new(sandbox(), uncreatable),
Err(InvalidHeadRef::InvalidBranchName),
"{uncreatable:?}"
);
}
}
#[test]
fn head_keeps_the_branch_characters_git_permits() {
for permitted in [
"refs/heads/mai\u{00a0}n",
"refs/heads/feature.lockfile",
"refs/heads/rele.ase",
"refs/heads/ma@in",
"refs/heads/feat/-topic",
"refs/heads/feat/HEAD",
] {
assert!(
ChangeRequestHead::new(sandbox(), permitted).is_ok(),
"{permitted:?}"
);
}
}
#[test]
fn findings_and_standalone_threads_remain_structurally_distinct() {
let reviewer = actor("reviewer");
let author = ReviewActor {
id: ReviewActorId::new("actor-author").expect("actor id"),
login: "author".to_owned(),
kind: ReviewActorKind::User,
};
let change_request = ChangeRequest {
number: ChangeRequestNumber::new(1).expect("number"),
title: "Author threads".to_owned(),
state: ChangeRequestState::Open,
draft: false,
commit_range: CommitRange {
base_sha: "base".to_owned(),
head_sha: "head".to_owned(),
},
base_branch: "main".to_owned(),
head: Some(
ChangeRequestHead::new(
Repository::new("civitas-forge", "sandbox").expect("repository"),
"refs/heads/author-threads",
)
.expect("head"),
),
mergeability: Mergeability::Mergeable,
author: author.clone(),
updated_at: Utc.timestamp_opt(2, 0).single().expect("timestamp"),
reviews: vec![review(
"review-1",
reviewer.clone(),
vec![ReviewFinding {
thread: thread("finding", reviewer),
resolution: None,
}],
)],
standalone_threads: vec![thread("standalone", author)],
unanchored_comments: Vec::new(),
outstanding_requests: Vec::new(),
};
assert_eq!(change_request.reviews[0].findings.len(), 1);
assert_eq!(change_request.standalone_threads.len(), 1);
}
#[test]
fn only_merged_change_requests_carry_a_merge_time() {
let merged_at = Utc.timestamp_opt(3, 0).single().expect("timestamp");
assert_eq!(
serde_json::to_value(ChangeRequestState::Open).expect("serializes open state"),
serde_json::json!("open")
);
assert_eq!(
serde_json::to_value(ChangeRequestState::Closed).expect("serializes closed state"),
serde_json::json!("closed")
);
assert_eq!(
serde_json::to_value(ChangeRequestState::Merged { merged_at })
.expect("serializes merged state"),
serde_json::json!({ "merged": { "merged_at": "1970-01-01T00:00:03Z" } })
);
assert_ne!(
ChangeRequestState::Closed,
ChangeRequestState::Merged { merged_at }
);
}
#[test]
fn finding_resolution_reasons_use_githubs_enum_spellings() {
for (reason, expected) in [
(FindingResolutionReason::Addressed, "ADDRESSED"),
(FindingResolutionReason::Invalid, "INVALID"),
(FindingResolutionReason::WontFix, "WONT_FIX"),
] {
let resolution = FindingResolution {
reason,
addressing_severity: FindingSeverity::Major,
};
assert_eq!(
serde_json::to_value(resolution).expect("serializes resolution"),
serde_json::json!({
"reason": expected,
"addressing_severity": "major"
})
);
}
}
#[test]
fn finding_resolution_replies_require_visible_explanatory_text() {
assert!(FindingResolutionReply::new("\n\t").is_err());
let reply = FindingResolutionReply::new("Addressed in the current revision.")
.expect("visible explanation");
assert_eq!(reply.as_str(), "Addressed in the current revision.");
}
#[test]
fn mergeability_keeps_an_uncomputed_merge_distinct_from_a_conflicted_one() {
for (mergeability, expected) in [
(Mergeability::Mergeable, "mergeable"),
(Mergeability::Conflicted, "conflicted"),
(Mergeability::Unknown, "unknown"),
] {
assert_eq!(
serde_json::to_value(mergeability).expect("serializes mergeability"),
serde_json::json!(expected)
);
}
assert_ne!(Mergeability::Unknown, Mergeability::Conflicted);
}
#[test]
fn every_status_before_completion_carries_no_conclusion() {
for status in [
CheckStatus::Requested,
CheckStatus::Queued,
CheckStatus::Pending,
CheckStatus::Waiting,
CheckStatus::InProgress,
] {
assert_eq!(status.conclusion(), None);
}
}
#[test]
fn an_observed_check_carries_a_conclusion_only_once_it_has_completed() {
let running = CheckRun {
name: "quality".to_owned(),
head_sha: "head".to_owned(),
via_app: None,
status: CheckStatus::InProgress,
summary: None,
html_url: None,
};
let completed = CheckRun {
status: CheckStatus::Completed {
conclusion: CheckConclusion::TimedOut,
completed_at: Utc.timestamp_opt(3, 0).single().expect("timestamp"),
},
summary: Some("The job exceeded its limit.".to_owned()),
..running.clone()
};
assert_eq!(running.status.conclusion(), None);
assert_eq!(
completed.status.conclusion(),
Some(CheckConclusion::TimedOut)
);
assert_eq!(
serde_json::to_value(&running.status).expect("serializes running status"),
serde_json::json!("in_progress")
);
assert_eq!(
serde_json::to_value(&completed.status).expect("serializes completed status"),
serde_json::json!({
"completed": {
"conclusion": "timed_out",
"completed_at": "1970-01-01T00:00:03Z"
}
})
);
}
#[test]
fn check_conclusions_cover_every_conclusion_a_check_run_reports() {
for (conclusion, expected) in [
(CheckConclusion::Success, "success"),
(CheckConclusion::Failure, "failure"),
(CheckConclusion::Neutral, "neutral"),
(CheckConclusion::Cancelled, "cancelled"),
(CheckConclusion::TimedOut, "timed_out"),
(CheckConclusion::ActionRequired, "action_required"),
(CheckConclusion::Skipped, "skipped"),
(CheckConclusion::Stale, "stale"),
] {
assert_eq!(
serde_json::to_value(conclusion).expect("serializes conclusion"),
serde_json::json!(expected)
);
}
}
#[test]
fn observed_target_kind_and_request_address_are_independent() {
let organization_team = ReviewRequest {
id: ReviewRequestId::new("request-organization").expect("request id"),
target: ReviewTarget::Team(ReviewTeam {
id: ReviewTeamId::new("team-organization").expect("team id"),
slug: "maintainers".to_owned(),
name: "Maintainers".to_owned(),
kind: ReviewTeamKind::Organization,
}),
request_target: None,
requested_at: None,
as_code_owner: false,
};
let enterprise_team = ReviewRequest {
id: ReviewRequestId::new("request-enterprise").expect("request id"),
target: ReviewTarget::Team(ReviewTeam {
id: ReviewTeamId::new("team-enterprise").expect("team id"),
slug: "security".to_owned(),
name: "Security".to_owned(),
kind: ReviewTeamKind::Enterprise,
}),
request_target: Some(ReviewRequestTarget::Team("security".to_owned())),
requested_at: Utc.timestamp_opt(3, 0).single(),
as_code_owner: false,
};
assert_eq!(organization_team.request_target, None);
assert_eq!(
enterprise_team.request_target,
Some(ReviewRequestTarget::Team("security".to_owned()))
);
assert_eq!(organization_team.requested_at, None);
assert_eq!(
enterprise_team.requested_at,
Utc.timestamp_opt(3, 0).single()
);
}
}