use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::{ChangeRequestHead, Review, ReviewActor, ReviewComment, ReviewRequest, ReviewThread};
platform_number!(ChangeRequestNumber);
#[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, 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>,
}
#[cfg(test)]
mod tests {
use chrono::TimeZone;
use super::*;
use crate::{
Repository, ReviewActorId, ReviewActorKind, ReviewAnchor, ReviewAuthor, ReviewCommentId,
ReviewDiffSide, ReviewDisposition, ReviewFinding, ReviewId, ReviewLine, ReviewLineRange,
ReviewLocation, ReviewState, ReviewThreadId, ReviewThreadStatus, ReviewedRevision,
};
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,
}
}
#[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 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);
}
}