use anyhow::{anyhow, Result};
use git2::{build::CheckoutBuilder, Oid, Repository};
use guppy::graph::PackageGraph;
use guppy::MetadataCommand;
use semver::Version;
use separator::Separatable;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::Path;
pub mod advisory;
pub mod code;
pub mod cratesio;
pub mod diff;
pub mod ghcomment;
pub mod github;
mod guppy_wrapper;
pub mod super_toml;
pub mod update;
use cratesio::CratesioReport;
use ghcomment::{Emoji::*, GitHubCommentGenerator, TextStyle::*};
use github::GitHubReport;
use guppy_wrapper::{
get_all_dependencies, get_dep_kind_map, get_direct_dependencies, DependencyKind,
};
use update::{CrateVersionRustSecAdvisory, UpdateReviewReport, VersionConflict};
#[derive(Serialize, Deserialize)]
pub struct PackageMetrics {
pub name: String,
pub is_direct: bool,
pub kind: DependencyKind,
pub cratesio_metrics: Option<CratesioReport>,
pub github_metrics: Option<GitHubReport>,
}
pub struct DependencyAnalyzer;
impl DependencyAnalyzer {
pub fn get_dep_pacakge_metrics_in_json_from_path(
path: &Path,
only_direct: bool,
) -> Result<String> {
let graph = MetadataCommand::new().current_dir(path).build_graph()?;
Self::get_dep_pacakge_metrics_in_json(&graph, only_direct)
}
fn get_dep_pacakge_metrics_in_json(graph: &PackageGraph, only_direct: bool) -> Result<String> {
let mut output: Vec<PackageMetrics> = Vec::new();
let all_deps = get_all_dependencies(graph);
let direct_deps: HashSet<(&str, &Version)> = get_direct_dependencies(graph)
.iter()
.map(|pkg| (pkg.name(), pkg.version()))
.collect();
let dep_kind_map = get_dep_kind_map(graph)?;
for dep in &all_deps {
let is_direct = direct_deps.contains(&(dep.name(), dep.version()));
if only_direct && !is_direct {
continue;
}
let kind = dep_kind_map
.get(&(dep.name().to_string(), dep.version().clone()))
.ok_or_else(|| {
anyhow!(
"fatal error in determining dependency kind for {}:{}",
dep.name(),
dep.version()
)
})?
.clone();
let cratesio_metrics = cratesio::CratesioAnalyzer::new()?;
let cratesio_metrics: Option<CratesioReport> =
cratesio_metrics.analyze_cratesio(dep).ok();
let github_metrics = github::GitHubAnalyzer::new()?;
let github_metrics: Option<GitHubReport> = github_metrics.analyze_github(dep).ok();
output.push(PackageMetrics {
name: dep.name().to_string(),
is_direct,
kind,
cratesio_metrics,
github_metrics,
});
}
let json_output = serde_json::to_string(&output)?;
Ok(json_output)
}
}
pub struct DependencyGraphAnalyzer;
impl DependencyGraphAnalyzer {
pub fn get_code_metrics_in_json_from_path(path: &Path, only_direct: bool) -> Result<String> {
let graph = MetadataCommand::new().current_dir(path).build_graph()?;
Self::get_code_metrics_in_json(&graph, only_direct)
}
fn get_code_metrics_in_json(graph: &PackageGraph, only_direct: bool) -> Result<String> {
let code_reports = code::CodeAnalyzer::new();
let reports = code_reports.analyze_code(graph, only_direct)?;
let json_output = serde_json::to_string(&reports)?;
Ok(json_output)
}
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct AdvisoryHighlight {
pub status: AdvisoryStatus,
pub crate_name: String,
pub id: String,
pub url: Option<String>,
}
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum AdvisoryStatus {
Fixed,
Introduced,
Unfixed, }
pub struct UpdateAnalyzer;
impl UpdateAnalyzer {
pub fn run_update_analyzer(
prior_graph: &PackageGraph,
post_graph: &PackageGraph,
) -> Result<UpdateReviewReport> {
let update_analyzer = update::UpdateAnalyzer::new();
update_analyzer.analyze_updates(prior_graph, post_graph)
}
pub fn get_summary_report(
prior_graph: &PackageGraph,
post_graph: &PackageGraph,
) -> Result<Option<String>> {
let update_review_report = Self::run_update_analyzer(prior_graph, post_graph)?;
if update_review_report.dep_update_review_reports.is_empty()
&& update_review_report.version_conflicts.is_empty()
{
return Ok(None);
}
let mut gh = GitHubCommentGenerator::new();
let mut advisory_highlights: HashSet<AdvisoryHighlight> = HashSet::new();
gh.add_header("Dependency update review", 2);
for report in &update_review_report.dep_update_review_reports {
gh.add_header(
&format!(
"{} updated: {} --> {}",
report.name, report.prior_version.version, report.updated_version.version
),
3,
);
let mut details: String = String::new();
let mut checkmark_table: Vec<Vec<&str>> = vec![vec![
"No known advisories",
GitHubCommentGenerator::get_checkmark(
report.updated_version.known_advisories.is_empty(),
),
]];
let mut add_to_advisory_highlights =
|a: &CrateVersionRustSecAdvisory, status: AdvisoryStatus| {
advisory_highlights.insert(AdvisoryHighlight {
status,
crate_name: report.name.clone(),
id: a.id.clone(),
url: a.url.clone().map(|url| url.to_string()),
})
};
report
.updated_version
.known_advisories
.iter()
.for_each(|a| {
let status = if report.prior_version.known_advisories.contains(a) {
AdvisoryStatus::Unfixed
} else {
AdvisoryStatus::Introduced
};
add_to_advisory_highlights(a, status);
});
report
.prior_version
.known_advisories
.iter()
.filter(|a| !report.updated_version.known_advisories.contains(a))
.for_each(|a| {
add_to_advisory_highlights(a, AdvisoryStatus::Fixed);
});
let get_hyperlink = |a: &CrateVersionRustSecAdvisory| {
if let Some(url) = &a.url {
GitHubCommentGenerator::get_hyperlink(&a.id, &url.to_string())
} else {
a.id.clone()
}
};
if !report.updated_version.known_advisories.is_empty() {
let ids: Vec<String> = report
.updated_version
.known_advisories
.iter()
.map(|a| get_hyperlink(a))
.collect();
gh.add_header(":bomb: The updated version contains known advisories", 3);
gh.add_bulleted_list(&ids, &Plain);
}
let fixed_advisories: Vec<String> = report
.prior_version
.known_advisories
.iter()
.filter(|a| !report.updated_version.known_advisories.contains(a))
.map(|a| get_hyperlink(a))
.collect();
if !fixed_advisories.is_empty() {
gh.add_header(":tada: This update fixes known advisories", 3);
gh.add_bulleted_list(&fixed_advisories, &Plain);
}
match &report.diff_stats {
None => checkmark_table.push(vec![
"Depdive failed to get the diff between versions from crates.io",
GitHubCommentGenerator::get_emoji(Warning),
]),
Some(stats) => {
details.push_str(&GitHubCommentGenerator::get_collapsible_section(
"Click to show version diff summary",
&GitHubCommentGenerator::get_html_table(&[
vec![
"total files changed".to_string(),
stats.files_changed.len().separated_string(),
],
vec![
"total rust files changed".to_string(),
stats.rust_files_changed.separated_string(),
],
vec![
"total loc change".to_string(),
(stats.insertions + stats.deletions).separated_string(),
],
]),
));
let changed_file_paths: Vec<String> =
stats.files_changed.iter().cloned().collect();
details.push_str(&GitHubCommentGenerator::get_collapsible_section(
"Click to show changed files",
&GitHubCommentGenerator::get_bulleted_list(&changed_file_paths, &Code),
));
checkmark_table.push(vec![
"No change in the build script",
GitHubCommentGenerator::get_checkmark(
stats.modified_build_scripts.is_empty(),
),
]);
if !stats.modified_build_scripts.is_empty() {
let paths: Vec<String> =
stats.modified_build_scripts.iter().cloned().collect();
details.push_str(&GitHubCommentGenerator::get_collapsible_section(
"Click to show modified build scripts",
&GitHubCommentGenerator::get_bulleted_list(&paths, &Code),
));
}
checkmark_table.push(vec![
"No change in any file with unsafe code",
GitHubCommentGenerator::get_checkmark(stats.unsafe_file_changed.is_empty()),
]);
if !stats.unsafe_file_changed.is_empty() {
let paths: Vec<String> = stats
.unsafe_file_changed
.iter()
.map(|stats| stats.file.clone())
.collect();
details.push_str(&GitHubCommentGenerator::get_collapsible_section(
"Click to show changed files with unsafe code",
&GitHubCommentGenerator::get_bulleted_list(&paths, &Code),
));
}
}
}
if let Some(crate_source_diff_report) = &report.updated_version.crate_source_diff_report
{
match crate_source_diff_report.is_different {
None => {
checkmark_table.push(vec![
"Depdive failed to compare the crates.io code with its git source",
GitHubCommentGenerator::get_emoji(Warning),
]);
}
Some(f) => {
checkmark_table.push(vec![
"The source and crates.io code are the same",
GitHubCommentGenerator::get_checkmark(!f),
]);
if f {
let changed_files = crate_source_diff_report
.file_diff_stats
.as_ref()
.ok_or_else(|| {
anyhow!("Cannot locate file paths in git source diff report")
})?;
let paths: Vec<String> = changed_files
.files_added
.union(&changed_files.files_modified)
.cloned()
.collect();
details.push_str(&GitHubCommentGenerator::get_collapsible_section(
"Click to show the files that differ in crates.io from the git source",
&GitHubCommentGenerator::get_bulleted_list(&paths, &Code),
));
}
}
}
} else {
return Err(anyhow!("no crates source diff report for the new version"));
}
gh.add_html_table(&checkmark_table);
gh.add_collapsible_section("Cilck to show details", &details);
}
if !update_review_report.version_conflicts.is_empty() {
let mut conflicts: Vec<String> = Vec::new();
for conflict in &update_review_report.version_conflicts {
match conflict {
VersionConflict::DirectTransitiveVersionConflict {
name,
direct_dep_version,
transitive_dep_version,
} => conflicts.push(format!(
"{} has version {} as a transitive dep but version {} as a direct dep",
name, transitive_dep_version, direct_dep_version
)),
}
}
gh.add_collapsible_section(
":warning: Possible dependency Conflicts",
&GitHubCommentGenerator::get_bulleted_list(&conflicts, &Plain),
);
}
let advisory_banner = Self::get_advisory_banner(&advisory_highlights);
Ok(Some(format!("{}\n{}", advisory_banner, gh.get_comment())))
}
fn get_advisory_banner(advisory_highlights: &HashSet<AdvisoryHighlight>) -> String {
let mut advisory_banner: String = String::new();
let introduced = advisory_highlights
.iter()
.filter(|a| a.status == AdvisoryStatus::Introduced)
.count();
if introduced > 0 {
advisory_banner.push_str(&GitHubCommentGenerator::get_header_text(
&format!(
":bomb: This update introduces {} known {}\n",
introduced,
advisory_text(introduced)
),
1,
));
}
let unfixed = advisory_highlights
.iter()
.filter(|a| a.status == AdvisoryStatus::Unfixed)
.count();
if unfixed > 0 {
advisory_banner.push_str(&GitHubCommentGenerator::get_header_text(
&format!(
":bomb: {} known {} still unfixed\n",
unfixed,
advisory_text(unfixed)
),
1,
));
}
let fixed = advisory_highlights
.iter()
.filter(|a| a.status == AdvisoryStatus::Fixed)
.count();
if fixed > 0 {
advisory_banner.push_str(&GitHubCommentGenerator::get_header_text(
&format!(
":tada: This update fixes {} known {}\n",
fixed,
advisory_text(fixed)
),
1,
));
}
fn advisory_text(n: usize) -> &'static str {
if n == 1 {
"advisory"
} else {
"advisories"
}
}
advisory_banner
}
pub fn run_update_analyzer_from_repo_commits(
path: &Path,
commit_a: &str,
commit_b: &str,
) -> Result<Option<String>> {
let repo = Repository::open(&path)?;
let starter_commit = repo.head()?.peel_to_commit()?;
let mut checkout_builder = CheckoutBuilder::new();
checkout_builder.force();
repo.checkout_tree(
&repo.find_object(Oid::from_str(commit_a)?, None)?,
Some(&mut checkout_builder),
)?;
let prior_graph = MetadataCommand::new().current_dir(path).build_graph()?;
repo.checkout_tree(
&repo.find_object(Oid::from_str(commit_b)?, None)?,
Some(&mut checkout_builder),
)?;
let post_graph = MetadataCommand::new().current_dir(path).build_graph()?;
repo.checkout_tree(starter_commit.as_object(), Some(&mut checkout_builder))?;
UpdateAnalyzer::get_summary_report(&prior_graph, &post_graph)
}
pub fn run_update_analyzer_from_paths(path_a: &Path, path_b: &Path) -> Result<Option<String>> {
let prior_graph = MetadataCommand::new().current_dir(path_a).build_graph()?;
let post_graph = MetadataCommand::new().current_dir(path_b).build_graph()?;
UpdateAnalyzer::get_summary_report(&prior_graph, &post_graph)
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::diff::DiffAnalyzer;
use once_cell::sync::Lazy;
use serial_test::serial;
use std::sync::Once;
static DIFF_ANALYZER: Lazy<DiffAnalyzer> = Lazy::new(|| DiffAnalyzer::new().unwrap());
static INIT_GIT_REPOS: Once = Once::new();
pub fn setup_git_repos() {
INIT_GIT_REPOS.call_once(|| {
let name = "diem";
let url = "https://github.com/diem/diem";
DIFF_ANALYZER.get_git_repo(name, url).unwrap();
let name = "octocrab";
let url = "https://github.com/XAMPPRocky/octocrab";
DIFF_ANALYZER.get_git_repo(name, url).unwrap();
});
}
#[test]
#[serial]
fn test_lib_update_review_report_from_repo_commits() {
setup_git_repos();
let name = "diem";
let repository = "https://github.com/diem/diem";
let repo = DIFF_ANALYZER.get_git_repo(name, repository).unwrap();
let path = repo
.path()
.parent()
.ok_or_else(|| anyhow!("repository path not found for {}", repository))
.unwrap();
println!(
"{}",
UpdateAnalyzer::run_update_analyzer_from_repo_commits(
path,
"20da44ad0918e6f260e9f150a60f28ec3b8665b2",
"2b2e529d96b6fbd9b5d111ecdd21acb61e95a28f"
)
.unwrap()
.unwrap()
);
}
#[test]
fn test_lib_update_review_report_from_paths() {
let mut checkout_builder = CheckoutBuilder::new();
checkout_builder.force();
let da = diff::DiffAnalyzer::new().unwrap();
let repo = da
.get_git_repo(
"test_a",
"https://github.com/nasifimtiazohi/test-version-tag",
)
.unwrap();
repo.checkout_tree(
&repo
.find_object(
Oid::from_str("43ffefddc15cc21725207e51f4d41d9931d197f2").unwrap(),
None,
)
.unwrap(),
Some(&mut checkout_builder),
)
.unwrap();
let path_a = repo.path().parent().unwrap();
let repo = da
.get_git_repo(
"test_b",
"https://github.com/nasifimtiazohi/test-version-tag",
)
.unwrap();
repo.checkout_tree(
&repo
.find_object(
Oid::from_str("96a541d081863875b169fc88cd8f58bbd268d377").unwrap(),
None,
)
.unwrap(),
Some(&mut checkout_builder),
)
.unwrap();
let path_b = repo.path().parent().unwrap();
println!(
"{}",
UpdateAnalyzer::run_update_analyzer_from_paths(path_a, path_b)
.unwrap()
.unwrap()
);
}
#[test]
#[serial]
fn test_lib_for_no_updates() {
setup_git_repos();
let name = "diem";
let repository = "https://github.com/diem/diem";
let repo = DIFF_ANALYZER.get_git_repo(name, repository).unwrap();
let path = repo
.path()
.parent()
.ok_or_else(|| anyhow!("repository path not found for {}", repository))
.unwrap();
assert!(UpdateAnalyzer::run_update_analyzer_from_repo_commits(
path,
"516b1d9cb619de459da0ba07e8fd74743d2fa9a0",
"44f91c93c0d0b522bac22d90028698e392fada41"
)
.unwrap()
.is_none());
}
}