pub mod github;
pub mod hooks;
use crate::error::Result;
use crate::models::{Critique, Problem};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReviewState {
Approved,
ChangesRequested,
Commented,
Dismissed,
}
#[derive(Debug, Clone)]
pub struct ReviewInfo {
pub id: u64,
pub author: String,
pub state: ReviewState,
pub body: String,
}
#[derive(Debug, Clone)]
pub struct ReviewThread {
pub comment_id: u64,
pub author: String,
pub body: String,
pub path: String,
pub line: Option<usize>,
pub is_resolved: bool,
pub is_outdated: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PrStatus {
Open,
Merged,
Closed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IssueStatus {
Open,
Closed,
}
pub trait SyncProvider {
fn name(&self) -> &str;
fn check_auth(&self) -> Result<String>;
fn detect_repo(&self) -> Result<(String, String)>;
fn import_issue(&self, number: u64) -> Result<Problem>;
fn list_unlinked_issues(
&self,
existing: &[(String, u64)],
label: Option<&str>,
) -> Result<Vec<(u64, String)>>;
fn create_issue(&self, problem: &Problem) -> Result<u64>;
fn create_pr(
&self,
solution: &crate::models::Solution,
problem: &Problem,
branch: &str,
) -> Result<u64>;
fn merge_pr(&self, number: u64) -> Result<()>;
fn close_issue(&self, number: u64) -> Result<()>;
fn reopen_issue(&self, number: u64) -> Result<()>;
fn pull_reviews(&self, pr_number: u64) -> Result<Vec<ReviewInfo>>;
fn pull_review_threads(&self, pr_number: u64) -> Result<Vec<ReviewThread>> {
let _ = pr_number;
Ok(vec![])
}
fn pr_status(&self, pr_number: u64) -> Result<PrStatus>;
fn issue_status(&self, number: u64) -> Result<IssueStatus>;
}
fn review_title(author: &str, body: &str) -> String {
let first_line = body.lines().next().unwrap_or("").trim();
if first_line.len() > 5 {
let truncated: String = first_line.chars().take(80).collect();
format!("@{}: {}", author, truncated)
} else {
format!("GitHub review from @{}", author)
}
}
pub fn review_to_critique(review: &ReviewInfo, solution_id: &str, critique_id: String) -> Critique {
let mut critique = Critique::new(
critique_id,
review_title(&review.author, &review.body),
solution_id.to_string(),
);
critique.author = Some(review.author.clone());
critique.argument = review.body.clone();
critique.github_review_id = Some(review.id);
match review.state {
ReviewState::ChangesRequested => {
critique.severity = crate::models::CritiqueSeverity::High;
}
ReviewState::Commented => {
critique.severity = crate::models::CritiqueSeverity::Medium;
}
_ => {}
}
critique
}
pub fn thread_to_critique(
thread: &ReviewThread,
solution_id: &str,
critique_id: String,
) -> Critique {
let mut critique = Critique::new(
critique_id,
review_title(&thread.author, &thread.body),
solution_id.to_string(),
);
critique.author = Some(thread.author.clone());
critique.argument = thread.body.clone();
critique.github_review_id = Some(thread.comment_id);
if !thread.path.is_empty() {
critique.file_path = Some(thread.path.clone());
critique.line_start = thread.line;
critique.line_end = thread.line;
}
if thread.is_outdated {
critique.argument = format!(
"{}\n\nNote: this comment is on an outdated diff hunk.",
critique.argument
);
}
critique
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::*;
fn make_review(state: ReviewState, author: &str, body: &str) -> ReviewInfo {
ReviewInfo {
id: 5001,
author: author.to_string(),
state,
body: body.to_string(),
}
}
#[test]
fn test_review_to_critique_changes_requested() {
let review = make_review(
ReviewState::ChangesRequested,
"alice",
"The error handling needs improvement.",
);
let critique = review_to_critique(&review, "S-10", "C-100".to_string());
assert_eq!(critique.id, "C-100");
assert_eq!(critique.solution_id, "S-10");
assert_eq!(
critique.title,
"@alice: The error handling needs improvement."
);
assert_eq!(critique.author, Some("alice".to_string()));
assert_eq!(critique.argument, "The error handling needs improvement.");
assert_eq!(critique.github_review_id, Some(5001));
assert_eq!(critique.severity, CritiqueSeverity::High);
assert_eq!(critique.status, CritiqueStatus::Open);
}
#[test]
fn test_review_to_critique_commented() {
let review = make_review(
ReviewState::Commented,
"bob",
"Consider using a match statement here.",
);
let critique = review_to_critique(&review, "S-20", "C-200".to_string());
assert_eq!(critique.severity, CritiqueSeverity::Medium);
assert_eq!(critique.author, Some("bob".to_string()));
assert_eq!(critique.argument, "Consider using a match statement here.");
}
#[test]
fn test_review_to_critique_approved_default_severity() {
let review = make_review(ReviewState::Approved, "carol", "Looks good to me!");
let critique = review_to_critique(&review, "S-30", "C-300".to_string());
assert_eq!(critique.severity, CritiqueSeverity::Medium);
assert_eq!(critique.author, Some("carol".to_string()));
assert_eq!(critique.argument, "Looks good to me!");
assert_eq!(critique.github_review_id, Some(5001));
assert_eq!(critique.title, "@carol: Looks good to me!");
}
#[test]
fn test_review_to_critique_dismissed_default_severity() {
let review = make_review(ReviewState::Dismissed, "dave", "Dismissed review.");
let critique = review_to_critique(&review, "S-40", "C-400".to_string());
assert_eq!(critique.severity, CritiqueSeverity::Medium);
assert_eq!(critique.title, "@dave: Dismissed review.");
}
#[test]
fn test_review_to_critique_empty_body() {
let review = make_review(ReviewState::ChangesRequested, "eve", "");
let critique = review_to_critique(&review, "S-50", "C-500".to_string());
assert_eq!(critique.argument, "");
assert_eq!(critique.severity, CritiqueSeverity::High);
assert_eq!(critique.title, "GitHub review from @eve");
}
#[test]
fn test_review_title_short_body_falls_back() {
assert_eq!(review_title("alice", "ok"), "GitHub review from @alice");
assert_eq!(review_title("bob", ""), "GitHub review from @bob");
assert_eq!(review_title("carol", "nope"), "GitHub review from @carol");
}
#[test]
fn test_review_title_uses_first_line() {
let body = "This line is used as title\nSecond line is ignored";
assert_eq!(
review_title("alice", body),
"@alice: This line is used as title"
);
}
#[test]
fn test_review_title_truncates_at_80_chars() {
let long = "a".repeat(100);
let title = review_title("bob", &long);
assert_eq!(title, format!("@bob: {}", "a".repeat(80)));
}
#[test]
fn test_thread_to_critique_with_location() {
let thread = ReviewThread {
comment_id: 99001,
author: "alice".to_string(),
body: "Missing error check here".to_string(),
path: "src/auth.rs".to_string(),
line: Some(42),
is_resolved: false,
is_outdated: false,
};
let critique = thread_to_critique(&thread, "S-10", "C-200".to_string());
assert_eq!(critique.title, "@alice: Missing error check here");
assert_eq!(critique.author, Some("alice".to_string()));
assert_eq!(critique.argument, "Missing error check here");
assert_eq!(critique.file_path, Some("src/auth.rs".to_string()));
assert_eq!(critique.line_start, Some(42));
assert_eq!(critique.github_review_id, Some(99001));
assert!(!critique.argument.contains("outdated"));
}
#[test]
fn test_thread_to_critique_outdated_adds_note() {
let thread = ReviewThread {
comment_id: 99002,
author: "bob".to_string(),
body: "Old comment on stale diff".to_string(),
path: "src/lib.rs".to_string(),
line: Some(10),
is_resolved: false,
is_outdated: true,
};
let critique = thread_to_critique(&thread, "S-20", "C-300".to_string());
assert!(critique.argument.contains("outdated"));
}
#[test]
fn test_thread_to_critique_no_location_when_path_empty() {
let thread = ReviewThread {
comment_id: 99003,
author: "carol".to_string(),
body: "General comment without file context".to_string(),
path: "".to_string(),
line: None,
is_resolved: false,
is_outdated: false,
};
let critique = thread_to_critique(&thread, "S-30", "C-400".to_string());
assert!(critique.file_path.is_none());
assert!(critique.line_start.is_none());
}
}