use bytes::{Buf, BufMut, Bytes, BytesMut};
use guts_collaboration::{
Comment, CommentTarget, Issue, IssueState, Label, PullRequest, PullRequestState, Review,
ReviewState,
};
use guts_storage::ObjectId;
use serde::{Deserialize, Serialize};
use crate::{P2PError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum CollaborationMessageType {
PullRequestCreated = 10,
PullRequestUpdated = 11,
IssueCreated = 12,
IssueUpdated = 13,
CommentCreated = 14,
ReviewCreated = 15,
SyncCollaborationRequest = 16,
SyncCollaborationResponse = 17,
}
impl CollaborationMessageType {
pub fn from_byte(b: u8) -> Result<Self> {
match b {
10 => Ok(CollaborationMessageType::PullRequestCreated),
11 => Ok(CollaborationMessageType::PullRequestUpdated),
12 => Ok(CollaborationMessageType::IssueCreated),
13 => Ok(CollaborationMessageType::IssueUpdated),
14 => Ok(CollaborationMessageType::CommentCreated),
15 => Ok(CollaborationMessageType::ReviewCreated),
16 => Ok(CollaborationMessageType::SyncCollaborationRequest),
17 => Ok(CollaborationMessageType::SyncCollaborationResponse),
_ => Err(P2PError::InvalidMessage(format!(
"unknown collaboration message type: {}",
b
))),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializablePullRequest {
pub id: u64,
pub repo_key: String,
pub number: u32,
pub title: String,
pub description: String,
pub author: String,
pub state: String,
pub source_branch: String,
pub target_branch: String,
pub source_commit: String,
pub target_commit: String,
pub labels: Vec<SerializableLabel>,
pub created_at: u64,
pub updated_at: u64,
pub merged_at: Option<u64>,
pub merged_by: Option<String>,
}
impl From<PullRequest> for SerializablePullRequest {
fn from(pr: PullRequest) -> Self {
Self {
id: pr.id,
repo_key: pr.repo_key,
number: pr.number,
title: pr.title,
description: pr.description,
author: pr.author,
state: pr.state.to_string(),
source_branch: pr.source_branch,
target_branch: pr.target_branch,
source_commit: pr.source_commit.to_hex(),
target_commit: pr.target_commit.to_hex(),
labels: pr.labels.into_iter().map(Into::into).collect(),
created_at: pr.created_at,
updated_at: pr.updated_at,
merged_at: pr.merged_at,
merged_by: pr.merged_by,
}
}
}
impl SerializablePullRequest {
pub fn into_pull_request(self) -> Result<PullRequest> {
let source_commit = ObjectId::from_hex(&self.source_commit)
.map_err(|e| P2PError::InvalidMessage(e.to_string()))?;
let target_commit = ObjectId::from_hex(&self.target_commit)
.map_err(|e| P2PError::InvalidMessage(e.to_string()))?;
let state = match self.state.as_str() {
"open" => PullRequestState::Open,
"closed" => PullRequestState::Closed,
"merged" => PullRequestState::Merged,
s => return Err(P2PError::InvalidMessage(format!("invalid PR state: {}", s))),
};
let mut pr = PullRequest::new(
self.id,
self.repo_key,
self.number,
self.title,
self.description,
self.author,
self.source_branch,
self.target_branch,
source_commit,
target_commit,
);
pr.id = self.id;
pr.number = self.number;
pr.state = state;
pr.created_at = self.created_at;
pr.updated_at = self.updated_at;
pr.merged_at = self.merged_at;
pr.merged_by = self.merged_by;
for label in self.labels {
pr.labels.push(label.into_label());
}
Ok(pr)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializableIssue {
pub id: u64,
pub repo_key: String,
pub number: u32,
pub title: String,
pub description: String,
pub author: String,
pub state: String,
pub labels: Vec<SerializableLabel>,
pub created_at: u64,
pub updated_at: u64,
pub closed_at: Option<u64>,
pub closed_by: Option<String>,
}
impl From<Issue> for SerializableIssue {
fn from(issue: Issue) -> Self {
Self {
id: issue.id,
repo_key: issue.repo_key,
number: issue.number,
title: issue.title,
description: issue.description,
author: issue.author,
state: issue.state.to_string(),
labels: issue.labels.into_iter().map(Into::into).collect(),
created_at: issue.created_at,
updated_at: issue.updated_at,
closed_at: issue.closed_at,
closed_by: issue.closed_by,
}
}
}
impl SerializableIssue {
pub fn into_issue(self) -> Result<Issue> {
let state = match self.state.as_str() {
"open" => IssueState::Open,
"closed" => IssueState::Closed,
s => {
return Err(P2PError::InvalidMessage(format!(
"invalid issue state: {}",
s
)))
}
};
let mut issue = Issue::new(
self.id,
self.repo_key,
self.number,
self.title,
self.description,
self.author,
);
issue.id = self.id;
issue.number = self.number;
issue.state = state;
issue.created_at = self.created_at;
issue.updated_at = self.updated_at;
issue.closed_at = self.closed_at;
issue.closed_by = self.closed_by;
for label in self.labels {
issue.labels.push(label.into_label());
}
Ok(issue)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializableLabel {
pub name: String,
pub color: String,
pub description: Option<String>,
}
impl From<Label> for SerializableLabel {
fn from(label: Label) -> Self {
Self {
name: label.name,
color: label.color,
description: label.description,
}
}
}
impl SerializableLabel {
pub fn into_label(self) -> Label {
let mut label = Label::new(self.name, self.color);
if let Some(desc) = self.description {
label = label.with_description(desc);
}
label
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializableComment {
pub id: u64,
pub target_type: String,
pub repo_key: String,
pub number: u32,
pub author: String,
pub body: String,
pub created_at: u64,
pub updated_at: u64,
}
impl From<Comment> for SerializableComment {
fn from(comment: Comment) -> Self {
let (target_type, repo_key, number) = match &comment.target {
CommentTarget::PullRequest { repo_key, number } => {
("pull_request".to_string(), repo_key.clone(), *number)
}
CommentTarget::Issue { repo_key, number } => {
("issue".to_string(), repo_key.clone(), *number)
}
};
Self {
id: comment.id,
target_type,
repo_key,
number,
author: comment.author,
body: comment.body,
created_at: comment.created_at,
updated_at: comment.updated_at,
}
}
}
impl SerializableComment {
pub fn into_comment(self) -> Result<Comment> {
let target = match self.target_type.as_str() {
"pull_request" => CommentTarget::pull_request(&self.repo_key, self.number),
"issue" => CommentTarget::issue(&self.repo_key, self.number),
t => {
return Err(P2PError::InvalidMessage(format!(
"invalid comment target type: {}",
t
)))
}
};
let mut comment = Comment::new(self.id, target, self.author, self.body);
comment.id = self.id;
comment.created_at = self.created_at;
comment.updated_at = self.updated_at;
Ok(comment)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializableReview {
pub id: u64,
pub repo_key: String,
pub pr_number: u32,
pub author: String,
pub state: String,
pub body: Option<String>,
pub commit_id: String,
pub created_at: u64,
}
impl From<Review> for SerializableReview {
fn from(review: Review) -> Self {
Self {
id: review.id,
repo_key: review.repo_key,
pr_number: review.pr_number,
author: review.author,
state: review.state.to_string(),
body: review.body,
commit_id: review.commit_id,
created_at: review.created_at,
}
}
}
impl SerializableReview {
pub fn into_review(self) -> Result<Review> {
let state = match self.state.as_str() {
"approved" => ReviewState::Approved,
"changes_requested" => ReviewState::ChangesRequested,
"commented" => ReviewState::Commented,
"dismissed" => ReviewState::Dismissed,
s => {
return Err(P2PError::InvalidMessage(format!(
"invalid review state: {}",
s
)))
}
};
let mut review = Review::new(
self.id,
self.repo_key,
self.pr_number,
self.author,
state,
self.commit_id,
);
if let Some(body) = self.body {
review = review.with_body(body);
}
review.id = self.id;
review.created_at = self.created_at;
Ok(review)
}
}
#[derive(Debug, Clone)]
pub enum CollaborationMessage {
PullRequestCreated(SerializablePullRequest),
PullRequestUpdated(SerializablePullRequest),
IssueCreated(SerializableIssue),
IssueUpdated(SerializableIssue),
CommentCreated(SerializableComment),
ReviewCreated(SerializableReview),
SyncCollaborationRequest { repo_key: String },
SyncCollaborationResponse {
repo_key: String,
pull_requests: Vec<SerializablePullRequest>,
issues: Vec<SerializableIssue>,
comments: Vec<SerializableComment>,
reviews: Vec<SerializableReview>,
},
}
impl CollaborationMessage {
pub fn encode(&self) -> Bytes {
let mut buf = BytesMut::new();
match self {
CollaborationMessage::PullRequestCreated(pr) => {
buf.put_u8(CollaborationMessageType::PullRequestCreated as u8);
let json = serde_json::to_vec(pr).unwrap();
buf.put_u32(json.len() as u32);
buf.put_slice(&json);
}
CollaborationMessage::PullRequestUpdated(pr) => {
buf.put_u8(CollaborationMessageType::PullRequestUpdated as u8);
let json = serde_json::to_vec(pr).unwrap();
buf.put_u32(json.len() as u32);
buf.put_slice(&json);
}
CollaborationMessage::IssueCreated(issue) => {
buf.put_u8(CollaborationMessageType::IssueCreated as u8);
let json = serde_json::to_vec(issue).unwrap();
buf.put_u32(json.len() as u32);
buf.put_slice(&json);
}
CollaborationMessage::IssueUpdated(issue) => {
buf.put_u8(CollaborationMessageType::IssueUpdated as u8);
let json = serde_json::to_vec(issue).unwrap();
buf.put_u32(json.len() as u32);
buf.put_slice(&json);
}
CollaborationMessage::CommentCreated(comment) => {
buf.put_u8(CollaborationMessageType::CommentCreated as u8);
let json = serde_json::to_vec(comment).unwrap();
buf.put_u32(json.len() as u32);
buf.put_slice(&json);
}
CollaborationMessage::ReviewCreated(review) => {
buf.put_u8(CollaborationMessageType::ReviewCreated as u8);
let json = serde_json::to_vec(review).unwrap();
buf.put_u32(json.len() as u32);
buf.put_slice(&json);
}
CollaborationMessage::SyncCollaborationRequest { repo_key } => {
buf.put_u8(CollaborationMessageType::SyncCollaborationRequest as u8);
let repo_bytes = repo_key.as_bytes();
buf.put_u16(repo_bytes.len() as u16);
buf.put_slice(repo_bytes);
}
CollaborationMessage::SyncCollaborationResponse {
repo_key,
pull_requests,
issues,
comments,
reviews,
} => {
buf.put_u8(CollaborationMessageType::SyncCollaborationResponse as u8);
let repo_bytes = repo_key.as_bytes();
buf.put_u16(repo_bytes.len() as u16);
buf.put_slice(repo_bytes);
let pr_json = serde_json::to_vec(pull_requests).unwrap();
buf.put_u32(pr_json.len() as u32);
buf.put_slice(&pr_json);
let issue_json = serde_json::to_vec(issues).unwrap();
buf.put_u32(issue_json.len() as u32);
buf.put_slice(&issue_json);
let comment_json = serde_json::to_vec(comments).unwrap();
buf.put_u32(comment_json.len() as u32);
buf.put_slice(&comment_json);
let review_json = serde_json::to_vec(reviews).unwrap();
buf.put_u32(review_json.len() as u32);
buf.put_slice(&review_json);
}
}
buf.freeze()
}
pub fn decode(data: &[u8]) -> Result<Self> {
if data.is_empty() {
return Err(P2PError::InvalidMessage(
"empty collaboration message".into(),
));
}
let msg_type = CollaborationMessageType::from_byte(data[0])?;
let mut payload = &data[1..];
match msg_type {
CollaborationMessageType::PullRequestCreated => {
let len = read_u32(&mut payload)?;
let pr: SerializablePullRequest = serde_json::from_slice(&payload[..len as usize])
.map_err(|e| P2PError::InvalidMessage(e.to_string()))?;
Ok(CollaborationMessage::PullRequestCreated(pr))
}
CollaborationMessageType::PullRequestUpdated => {
let len = read_u32(&mut payload)?;
let pr: SerializablePullRequest = serde_json::from_slice(&payload[..len as usize])
.map_err(|e| P2PError::InvalidMessage(e.to_string()))?;
Ok(CollaborationMessage::PullRequestUpdated(pr))
}
CollaborationMessageType::IssueCreated => {
let len = read_u32(&mut payload)?;
let issue: SerializableIssue = serde_json::from_slice(&payload[..len as usize])
.map_err(|e| P2PError::InvalidMessage(e.to_string()))?;
Ok(CollaborationMessage::IssueCreated(issue))
}
CollaborationMessageType::IssueUpdated => {
let len = read_u32(&mut payload)?;
let issue: SerializableIssue = serde_json::from_slice(&payload[..len as usize])
.map_err(|e| P2PError::InvalidMessage(e.to_string()))?;
Ok(CollaborationMessage::IssueUpdated(issue))
}
CollaborationMessageType::CommentCreated => {
let len = read_u32(&mut payload)?;
let comment: SerializableComment = serde_json::from_slice(&payload[..len as usize])
.map_err(|e| P2PError::InvalidMessage(e.to_string()))?;
Ok(CollaborationMessage::CommentCreated(comment))
}
CollaborationMessageType::ReviewCreated => {
let len = read_u32(&mut payload)?;
let review: SerializableReview =
serde_json::from_slice(&payload[..len as usize])
.map_err(|e| P2PError::InvalidMessage(e.to_string()))?;
Ok(CollaborationMessage::ReviewCreated(review))
}
CollaborationMessageType::SyncCollaborationRequest => {
let repo_len = read_u16(&mut payload)?;
let repo_key = String::from_utf8(payload[..repo_len as usize].to_vec())
.map_err(|e| P2PError::InvalidMessage(e.to_string()))?;
Ok(CollaborationMessage::SyncCollaborationRequest { repo_key })
}
CollaborationMessageType::SyncCollaborationResponse => {
let repo_len = read_u16(&mut payload)?;
let repo_key = String::from_utf8(payload[..repo_len as usize].to_vec())
.map_err(|e| P2PError::InvalidMessage(e.to_string()))?;
payload = &payload[repo_len as usize..];
let pr_len = read_u32(&mut payload)?;
let pull_requests: Vec<SerializablePullRequest> =
serde_json::from_slice(&payload[..pr_len as usize])
.map_err(|e| P2PError::InvalidMessage(e.to_string()))?;
payload = &payload[pr_len as usize..];
let issue_len = read_u32(&mut payload)?;
let issues: Vec<SerializableIssue> =
serde_json::from_slice(&payload[..issue_len as usize])
.map_err(|e| P2PError::InvalidMessage(e.to_string()))?;
payload = &payload[issue_len as usize..];
let comment_len = read_u32(&mut payload)?;
let comments: Vec<SerializableComment> =
serde_json::from_slice(&payload[..comment_len as usize])
.map_err(|e| P2PError::InvalidMessage(e.to_string()))?;
payload = &payload[comment_len as usize..];
let review_len = read_u32(&mut payload)?;
let reviews: Vec<SerializableReview> =
serde_json::from_slice(&payload[..review_len as usize])
.map_err(|e| P2PError::InvalidMessage(e.to_string()))?;
Ok(CollaborationMessage::SyncCollaborationResponse {
repo_key,
pull_requests,
issues,
comments,
reviews,
})
}
}
}
}
fn read_u16(buf: &mut &[u8]) -> Result<u16> {
if buf.remaining() < 2 {
return Err(P2PError::InvalidMessage("truncated u16".into()));
}
Ok(buf.get_u16())
}
fn read_u32(buf: &mut &[u8]) -> Result<u32> {
if buf.remaining() < 4 {
return Err(P2PError::InvalidMessage("truncated u32".into()));
}
Ok(buf.get_u32())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pr_message_roundtrip() {
let pr = SerializablePullRequest {
id: 1,
repo_key: "alice/repo".to_string(),
number: 1,
title: "Add feature".to_string(),
description: "Description".to_string(),
author: "alice".to_string(),
state: "open".to_string(),
source_branch: "feature".to_string(),
target_branch: "main".to_string(),
source_commit: "0".repeat(40),
target_commit: "1".repeat(40),
labels: vec![],
created_at: 12345,
updated_at: 12345,
merged_at: None,
merged_by: None,
};
let msg = CollaborationMessage::PullRequestCreated(pr.clone());
let encoded = msg.encode();
let decoded = CollaborationMessage::decode(&encoded).unwrap();
match decoded {
CollaborationMessage::PullRequestCreated(decoded_pr) => {
assert_eq!(decoded_pr.id, pr.id);
assert_eq!(decoded_pr.title, pr.title);
assert_eq!(decoded_pr.number, pr.number);
}
_ => panic!("wrong message type"),
}
}
#[test]
fn test_issue_message_roundtrip() {
let issue = SerializableIssue {
id: 2,
repo_key: "bob/project".to_string(),
number: 5,
title: "Bug report".to_string(),
description: "Steps to reproduce".to_string(),
author: "bob".to_string(),
state: "open".to_string(),
labels: vec![SerializableLabel {
name: "bug".to_string(),
color: "ff0000".to_string(),
description: Some("A bug".to_string()),
}],
created_at: 54321,
updated_at: 54321,
closed_at: None,
closed_by: None,
};
let msg = CollaborationMessage::IssueCreated(issue.clone());
let encoded = msg.encode();
let decoded = CollaborationMessage::decode(&encoded).unwrap();
match decoded {
CollaborationMessage::IssueCreated(decoded_issue) => {
assert_eq!(decoded_issue.id, issue.id);
assert_eq!(decoded_issue.title, issue.title);
assert_eq!(decoded_issue.labels.len(), 1);
}
_ => panic!("wrong message type"),
}
}
#[test]
fn test_sync_request_roundtrip() {
let msg = CollaborationMessage::SyncCollaborationRequest {
repo_key: "carol/test".to_string(),
};
let encoded = msg.encode();
let decoded = CollaborationMessage::decode(&encoded).unwrap();
match decoded {
CollaborationMessage::SyncCollaborationRequest { repo_key } => {
assert_eq!(repo_key, "carol/test");
}
_ => panic!("wrong message type"),
}
}
}