use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::Result;
use crate::model::{DiffFile, LineSide};
use crate::vcs::CommitInfo;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrId {
pub owner: String,
pub repo: String,
pub number: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrMetadata {
pub title: String,
pub body: String,
pub author: String,
pub state: PrState,
pub base_branch: String,
pub head_branch: String,
pub head_sha: String,
pub created_at: DateTime<Utc>,
pub mergeable: Option<MergeableStatus>,
pub is_draft: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PrState {
Open,
Closed,
Merged,
}
impl std::fmt::Display for PrState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PrState::Open => write!(f, "open"),
PrState::Closed => write!(f, "closed"),
PrState::Merged => write!(f, "merged"),
}
}
}
impl PrState {
#[must_use]
pub fn display(&self) -> &'static str {
match self {
PrState::Open => "open",
PrState::Closed => "closed",
PrState::Merged => "merged",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrListItem {
pub number: u64,
pub title: String,
pub author: String,
pub state: PrState,
pub is_draft: bool,
pub base_branch: String,
pub head_branch: String,
pub updated_at: DateTime<Utc>,
pub comment_count: u32,
pub has_review_requested_from_me: bool,
#[serde(default)]
pub assignees: Vec<String>,
#[serde(default)]
pub reviewers: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrListFilter {
pub state: Option<PrState>,
pub author: Option<String>,
pub assignee: Option<String>,
pub review_requested: Option<bool>,
pub max: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MergeableStatus {
Clean,
Unstable,
Behind,
Blocked,
Dirty,
Draft,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MergeMethod {
Merge,
Squash,
Rebase,
}
impl std::fmt::Display for MergeMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MergeMethod::Merge => write!(f, "merge"),
MergeMethod::Squash => write!(f, "squash"),
MergeMethod::Rebase => write!(f, "rebase"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ReviewVerdict {
Approve,
RequestChanges,
Comment,
}
impl std::fmt::Display for ReviewVerdict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ReviewVerdict::Approve => write!(f, "approve"),
ReviewVerdict::RequestChanges => write!(f, "request_changes"),
ReviewVerdict::Comment => write!(f, "comment"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RemoteComment {
pub id: u64,
pub author: String,
pub body: String,
pub path: Option<String>,
pub line: Option<u32>,
pub side: Option<LineSide>,
pub created_at: DateTime<Utc>,
pub in_reply_to: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewThread {
pub id: String,
pub is_resolved: bool,
pub root_comment_id: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewComment {
pub path: String,
pub line: u32,
pub side: LineSide,
pub body: String,
pub start_line: Option<u32>,
pub commit_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewReview {
pub verdict: ReviewVerdict,
pub body: String,
pub comments: Vec<NewComment>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ReactionContent {
ThumbsUp,
ThumbsDown,
Laugh,
Hooray,
Confused,
Heart,
Rocket,
Eyes,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReactionTarget {
IssueComment(u64),
ReviewComment(u64),
Review(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
pub login: String,
pub id: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Permissions {
pub can_push: bool,
pub can_merge: bool,
pub allowed_merge_methods: Vec<MergeMethod>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[must_use]
pub enum ForgeType {
GitHub,
GitLab,
}
impl std::fmt::Display for ForgeType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ForgeType::GitHub => write!(f, "GitHub"),
ForgeType::GitLab => write!(f, "GitLab"),
}
}
}
pub type ForgeWarnHandler = std::sync::Arc<dyn Fn(String) + Send + Sync>;
#[async_trait]
pub trait ForgeRead: Send + Sync {
fn forge_type(&self) -> ForgeType;
async fn get_pr(&self, id: &PrId) -> Result<PrMetadata>;
async fn get_pr_commits(&self, id: &PrId) -> Result<Vec<CommitInfo>>;
async fn get_pr_files(&self, id: &PrId) -> Result<Vec<DiffFile>>;
async fn get_commit_diff(&self, id: &PrId, commit_sha: &str) -> Result<Vec<DiffFile>>;
async fn list_prs(
&self,
owner: &str,
repo: &str,
filter: &PrListFilter,
) -> Result<Vec<PrListItem>>;
async fn current_user(&self) -> Result<User>;
async fn check_permissions(&self, id: &PrId) -> Result<Permissions>;
}
#[async_trait]
pub trait ForgeComments: Send + Sync {
async fn get_comments(&self, id: &PrId) -> Result<Vec<RemoteComment>>;
async fn get_review_threads(&self, id: &PrId) -> Result<Vec<ReviewThread>>;
async fn post_comment(&self, id: &PrId, comment: NewComment) -> Result<RemoteComment>;
async fn post_reply(&self, id: &PrId, thread_id: &str, body: &str) -> Result<RemoteComment>;
async fn edit_comment(&self, id: &PrId, comment_id: u64, body: &str) -> Result<RemoteComment>;
async fn delete_comment(&self, id: &PrId, comment_id: u64) -> Result<()>;
async fn resolve_thread(&self, thread_id: &str) -> Result<()>;
async fn unresolve_thread(&self, thread_id: &str) -> Result<()>;
}
#[async_trait]
pub trait ForgeReview: Send + Sync {
async fn submit_review(&self, id: &PrId, review: NewReview) -> Result<()>;
}
#[async_trait]
pub trait ForgeMerge: Send + Sync {
async fn merge(&self, id: &PrId, method: MergeMethod) -> Result<()>;
async fn close(&self, id: &PrId) -> Result<()>;
async fn reopen(&self, id: &PrId) -> Result<()>;
}
#[async_trait]
pub trait ForgeReactions: Send + Sync {
async fn add_reaction(&self, target: &ReactionTarget, content: ReactionContent) -> Result<()>;
async fn remove_reaction(
&self,
target: &ReactionTarget,
content: ReactionContent,
) -> Result<()>;
}
pub trait ForgeBackend:
ForgeRead + ForgeComments + ForgeReview + ForgeMerge + ForgeReactions
{
}
impl<T: ForgeRead + ForgeComments + ForgeReview + ForgeMerge + ForgeReactions + ?Sized> ForgeBackend
for T
{
}
pub trait ForgeReadComments: ForgeRead + ForgeComments {}
impl<T: ForgeRead + ForgeComments + ?Sized> ForgeReadComments for T {}
pub trait ForgeMcp: ForgeRead + ForgeComments + ForgeReview {}
impl<T: ForgeRead + ForgeComments + ForgeReview + ?Sized> ForgeMcp for T {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pr_id_construction() {
let id = PrId {
owner: "octocat".into(),
repo: "hello-world".into(),
number: 42,
};
assert_eq!(id.owner, "octocat");
assert_eq!(id.number, 42);
}
#[test]
fn pr_id_equality() {
let a = PrId {
owner: "o".into(),
repo: "r".into(),
number: 1,
};
let b = PrId {
owner: "o".into(),
repo: "r".into(),
number: 1,
};
assert_eq!(a, b);
}
#[test]
fn pr_metadata_construction() {
let meta = PrMetadata {
title: "feat: add login".into(),
body: String::new(),
author: "alice".into(),
state: PrState::Open,
base_branch: "main".into(),
head_branch: "feat-login".into(),
head_sha: "abc123".into(),
created_at: Utc::now(),
mergeable: Some(MergeableStatus::Clean),
is_draft: false,
};
assert_eq!(meta.state, PrState::Open);
assert!(!meta.is_draft);
}
#[test]
fn new_comment_equality() {
let c1 = NewComment {
path: "src/lib.rs".into(),
line: 10,
side: LineSide::New,
body: "fix this".into(),
start_line: None,
commit_id: None,
};
let c2 = NewComment {
path: "src/lib.rs".into(),
line: 10,
side: LineSide::New,
body: "fix this".into(),
start_line: None,
commit_id: None,
};
assert_eq!(c1, c2);
}
#[test]
fn debug_output_is_reasonable() {
let state = PrState::Merged;
let dbg = format!("{state:?}");
assert_eq!(dbg, "Merged");
}
#[test]
fn reaction_content_all_variants() {
let variants = [
ReactionContent::ThumbsUp,
ReactionContent::ThumbsDown,
ReactionContent::Laugh,
ReactionContent::Hooray,
ReactionContent::Confused,
ReactionContent::Heart,
ReactionContent::Rocket,
ReactionContent::Eyes,
];
assert_eq!(variants.len(), 8);
for (i, a) in variants.iter().enumerate() {
for (j, b) in variants.iter().enumerate() {
assert_eq!(i == j, a == b);
}
}
}
#[test]
fn forge_type_variants() {
assert_ne!(ForgeType::GitHub, ForgeType::GitLab);
let gh = ForgeType::GitHub;
let gl = ForgeType::GitLab;
assert_eq!(gh, ForgeType::GitHub);
assert_eq!(gl, ForgeType::GitLab);
}
#[test]
fn user_equality() {
let a = User {
login: "alice".into(),
id: 1,
};
let b = User {
login: "alice".into(),
id: 1,
};
assert_eq!(a, b);
}
#[test]
fn pr_list_filter_default_is_empty() {
let f = PrListFilter::default();
assert!(f.state.is_none());
assert!(f.author.is_none());
assert!(f.assignee.is_none());
assert!(f.review_requested.is_none());
assert!(f.max.is_none());
}
#[test]
fn pr_list_item_construction() {
let item = PrListItem {
number: 7,
title: "feat: do the thing".into(),
author: "alice".into(),
state: PrState::Open,
is_draft: false,
base_branch: "main".into(),
head_branch: "feat".into(),
updated_at: Utc::now(),
comment_count: 2,
has_review_requested_from_me: true,
assignees: vec![],
reviewers: vec![],
};
assert_eq!(item.number, 7);
assert_eq!(item.state, PrState::Open);
assert!(item.has_review_requested_from_me);
}
}