diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 58191be..7d3a2fa 100644 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -9,6 +9,12 @@ # Types allowed: feat, fix, docs, style, refactor, test, chore, ci, build, perf, revert subject=$(head -1 "$1") + +# Allow system merge commits (GitHub, Git merges) +if echo "$subject" | grep -qE "^(Merge|Squashed|Revert)"; then + exit 0 +fi + pattern='^(feat|fix|docs|style|refactor|test|chore|ci|build|perf|revert)(\(.+\))?!?: .+' if ! echo "$subject" | grep -qE "$pattern"; then diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0b47e7..400671c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,11 @@ jobs: while IFS= read -r msg; do subject=$(echo "$msg" | head -1) + # Allow system merge commits + if echo "$subject" | grep -qE "^(Merge|Squashed|Revert)"; then + echo "✅ \"$subject\" (merge)" + continue + fi if ! echo "$subject" | grep -qE "$pattern"; then echo "❌ \"$subject\"" failed=1 @@ -40,6 +45,7 @@ jobs: echo "Un ou plusieurs commits ne respectent pas Conventional Commits." echo "Format attendu : type(scope)?: sujet" echo "Types autorisés : feat | fix | docs | style | refactor | test | chore | ci | build | perf | revert" + echo "(Les commits de merge Merge/Squashed/Revert sont toujours acceptés)" exit 1 fi diff --git a/src/checks/engine.rs b/src/checks/engine.rs index 2d8f904..599c300 100644 --- a/src/checks/engine.rs +++ b/src/checks/engine.rs @@ -61,7 +61,12 @@ impl CheckEngine { // Warnings count as passes; Skipped checks are excluded from the total let passed: u32 = cat_results .iter() - .filter(|r| matches!(r.status, crate::models::CheckStatus::Passed | crate::models::CheckStatus::Warning)) + .filter(|r| { + matches!( + r.status, + crate::models::CheckStatus::Passed | crate::models::CheckStatus::Warning + ) + }) .count() as u32; let total: u32 = cat_results .iter() @@ -84,7 +89,10 @@ impl CheckEngine { passed: global_passed, total: global_total, categories, - analyzed_at: js_sys::Date::new_0().to_iso_string().as_string().unwrap_or_default(), + analyzed_at: js_sys::Date::new_0() + .to_iso_string() + .as_string() + .unwrap_or_default(), }) } } diff --git a/src/checks/runner.rs b/src/checks/runner.rs index 657fb63..f311515 100644 --- a/src/checks/runner.rs +++ b/src/checks/runner.rs @@ -11,8 +11,7 @@ fn is_conventional_commit(message: &str) -> bool { "revert", ]; for prefix in &prefixes { - if subject.starts_with(prefix) { - let rest = &subject[prefix.len()..]; + if let Some(rest) = subject.strip_prefix(prefix) { // "prefix: " or "prefix!: " if rest.starts_with(": ") || rest.starts_with("!: ") { return true; @@ -31,7 +30,6 @@ fn is_conventional_commit(message: &str) -> bool { false } - /// Runs individual checks against GitHub API data pub struct CheckRunner<'a> { client: &'a GithubClient, @@ -85,9 +83,7 @@ impl<'a> CheckRunner<'a> { Ok(files) => { let yaml_files: Vec<&GithubContent> = files .iter() - .filter(|f| { - f.name.ends_with(".yml") || f.name.ends_with(".yaml") - }) + .filter(|f| f.name.ends_with(".yml") || f.name.ends_with(".yaml")) .collect(); if yaml_files.is_empty() { @@ -97,11 +93,14 @@ impl<'a> CheckRunner<'a> { "Créez un fichier .github/workflows/ci.yml pour votre pipeline CI/CD", ) } else { - let names: Vec = - yaml_files.iter().map(|f| f.name.clone()).collect(); + let names: Vec = yaml_files.iter().map(|f| f.name.clone()).collect(); CheckResult::passed( check, - format!("{} workflow(s) trouvé(s) : {}", names.len(), names.join(", ")), + format!( + "{} workflow(s) trouvé(s) : {}", + names.len(), + names.join(", ") + ), ) } } @@ -145,7 +144,10 @@ impl<'a> CheckRunner<'a> { ), } } - Err(_) => CheckResult::skipped(check, "Impossible de récupérer les runs (repo privé ou pas de workflows)"), + Err(_) => CheckResult::skipped( + check, + "Impossible de récupérer les runs (repo privé ou pas de workflows)", + ), } } @@ -240,10 +242,10 @@ impl<'a> CheckRunner<'a> { let workflow_content = self.aggregate_workflow_content().await; let secret_patterns = [ - "AKIA", // AWS access key prefix - "sk-", // OpenAI / Stripe key prefix - "ghp_", // GitHub PAT - "password: ", // Inline password + "AKIA", // AWS access key prefix + "sk-", // OpenAI / Stripe key prefix + "ghp_", // GitHub PAT + "password: ", // Inline password "passwd", "secret_key", ]; @@ -259,10 +261,7 @@ impl<'a> CheckRunner<'a> { } else { CheckResult::failed( check, - format!( - "Patterns suspects détectés : {}", - found_secrets.join(", ") - ), + format!("Patterns suspects détectés : {}", found_secrets.join(", ")), "Utilisez des GitHub Secrets (${{ secrets.MY_SECRET }}) au lieu de valeurs en dur", ) } @@ -339,10 +338,7 @@ impl<'a> CheckRunner<'a> { "Ajoutez un outil de coverage (codecov, tarpaulin, istanbul) dans votre CI", ) } else { - CheckResult::passed( - check, - format!("Coverage détectée : {}", found.join(", ")), - ) + CheckResult::passed(check, format!("Coverage détectée : {}", found.join(", "))) } } @@ -415,11 +411,18 @@ impl<'a> CheckRunner<'a> { let completed_runs: Vec<&WorkflowRun> = runs .workflow_runs .iter() - .filter(|r| r.conclusion.is_some() && r.run_started_at.is_some() && r.updated_at.is_some()) + .filter(|r| { + r.conclusion.is_some() + && r.run_started_at.is_some() + && r.updated_at.is_some() + }) .collect(); if completed_runs.is_empty() { - return CheckResult::skipped(check, "Pas assez de runs pour évaluer la vitesse"); + return CheckResult::skipped( + check, + "Pas assez de runs pour évaluer la vitesse", + ); } // Simple duration estimation: we can't do precise parsing in WASM easily, @@ -459,7 +462,10 @@ impl<'a> CheckRunner<'a> { if has_multi_env { CheckResult::passed( check, - format!("Indicateurs multi-environnement détectés : {}", found.join(", ")), + format!( + "Indicateurs multi-environnement détectés : {}", + found.join(", ") + ), ) } else { CheckResult::failed( @@ -475,33 +481,17 @@ impl<'a> CheckRunner<'a> { let content_lower = workflow_content.to_lowercase(); let deploy_indicators = [ - "deploy", - "publish", - "release", - "gh-pages", - "pages", - "aws", - "azure", - "gcloud", - "heroku", - "vercel", - "netlify", - "render", - "fly.io", + "deploy", "publish", "release", "gh-pages", "pages", "aws", "azure", "gcloud", + "heroku", "vercel", "netlify", "render", "fly.io", ]; let has_push_trigger = content_lower.contains("on:\n push:") || content_lower.contains("on: [push"); - let has_deploy = deploy_indicators - .iter() - .any(|d| content_lower.contains(d)); + let has_deploy = deploy_indicators.iter().any(|d| content_lower.contains(d)); if has_push_trigger && has_deploy { - CheckResult::passed( - check, - "Déploiement automatique détecté sur push", - ) + CheckResult::passed(check, "Déploiement automatique détecté sur push") } else if has_deploy { CheckResult::warning( check, @@ -525,10 +515,7 @@ impl<'a> CheckRunner<'a> { .client .file_exists(self.repo, ".github/CODEOWNERS") .await - || self - .client - .file_exists(self.repo, "docs/CODEOWNERS") - .await; + || self.client.file_exists(self.repo, "docs/CODEOWNERS").await; if exists { CheckResult::passed(check, "Fichier CODEOWNERS trouvé") @@ -581,7 +568,10 @@ impl<'a> CheckRunner<'a> { ), Some(c) => CheckResult::failed( check, - format!("Pipeline terminé avec le statut '{}' — les tests ont peut-être échoué", c), + format!( + "Pipeline terminé avec le statut '{}' — les tests ont peut-être échoué", + c + ), "Corrigez les tests en échec pour passer ce check", ), None => CheckResult::skipped(check, "Run encore en cours"), @@ -605,10 +595,7 @@ impl<'a> CheckRunner<'a> { || content_lower.contains("build-push-action"); if has_ghcr && has_push { - CheckResult::passed( - check, - "Publication vers ghcr.io détectée dans le pipeline", - ) + CheckResult::passed(check, "Publication vers ghcr.io détectée dans le pipeline") } else if has_ghcr { CheckResult::warning( check, @@ -688,10 +675,7 @@ impl<'a> CheckRunner<'a> { }; if !cache_type.is_empty() { - CheckResult::passed( - check, - format!("Cache CI détecté : {}", cache_type), - ) + CheckResult::passed(check, format!("Cache CI détecté : {}", cache_type)) } else { CheckResult::failed( check, @@ -787,7 +771,10 @@ impl<'a> CheckRunner<'a> { if defines_reusable { CheckResult::passed(check, "Workflow réutilisable défini (workflow_call) — peut être invoqué par d'autres repos") } else if calls_reusable { - CheckResult::passed(check, "Workflow réutilisable appelé (uses: ./.github/workflows/) — bonne pratique DRY") + CheckResult::passed( + check, + "Workflow réutilisable appelé (uses: ./.github/workflows/) — bonne pratique DRY", + ) } else { CheckResult::failed( check, @@ -949,7 +936,10 @@ impl<'a> CheckRunner<'a> { if !found.is_empty() { return CheckResult::passed( check, - format!("Outil de changelog automatisé détecté : {}", found.join(", ")), + format!( + "Outil de changelog automatisé détecté : {}", + found.join(", ") + ), ); } @@ -1004,20 +994,14 @@ impl<'a> CheckRunner<'a> { || content_lower.contains("undo-deploy") || content_lower.contains("undo_deploy") { - return CheckResult::passed( - check, - "Mécanisme de rollback détecté dans les workflows", - ); + return CheckResult::passed(check, "Mécanisme de rollback détecté dans les workflows"); } // Check for workflow_dispatch with rollback input (manual redeploy) if workflow_content.contains("workflow_dispatch:") && (content_lower.contains("revert") || content_lower.contains("rollback")) { - return CheckResult::passed( - check, - "workflow_dispatch avec option de revert détecté", - ); + return CheckResult::passed(check, "workflow_dispatch avec option de revert détecté"); } // Partial credit: workflow_dispatch alone = manual recovery possible @@ -1049,7 +1033,9 @@ impl<'a> CheckRunner<'a> { for file in &files { let is_yaml = file.name.ends_with(".yml") || file.name.ends_with(".yaml"); if is_yaml { - if let Ok(file_content) = self.client.fetch_file_content(self.repo, &file.path).await { + if let Ok(file_content) = + self.client.fetch_file_content(self.repo, &file.path).await + { content.push_str(&file_content); content.push('\n'); } diff --git a/src/components/search_bar.rs b/src/components/search_bar.rs index eb202e6..428d8ad 100644 --- a/src/components/search_bar.rs +++ b/src/components/search_bar.rs @@ -29,11 +29,7 @@ pub fn search_bar(props: &SearchBarProps) -> Html { .unwrap_or_default(); if !url.is_empty() { - let token = if token.is_empty() { - None - } else { - Some(token) - }; + let token = if token.is_empty() { None } else { Some(token) }; on_analyze.emit((url, token)); } }) diff --git a/src/models/check.rs b/src/models/check.rs index b644be5..89741fc 100644 --- a/src/models/check.rs +++ b/src/models/check.rs @@ -72,11 +72,7 @@ impl CheckResult { } } - pub fn failed( - check: Check, - detail: impl Into, - suggestion: impl Into, - ) -> Self { + pub fn failed(check: Check, detail: impl Into, suggestion: impl Into) -> Self { Self { check, status: CheckStatus::Failed, @@ -85,11 +81,7 @@ impl CheckResult { } } - pub fn warning( - check: Check, - detail: impl Into, - suggestion: impl Into, - ) -> Self { + pub fn warning(check: Check, detail: impl Into, suggestion: impl Into) -> Self { Self { check, status: CheckStatus::Warning, diff --git a/src/services/client.rs b/src/services/client.rs index d7bb682..635211b 100644 --- a/src/services/client.rs +++ b/src/services/client.rs @@ -57,18 +57,11 @@ impl GithubClient { } } - async fn fetch_json( - &self, - url: &str, - ) -> Result { - let response = self - .build_request(url) - .send() - .await - .map_err(|e| ApiError { - status: 0, - message: format!("Network error: {}", e), - })?; + async fn fetch_json(&self, url: &str) -> Result { + let response = self.build_request(url).send().await.map_err(|e| ApiError { + status: 0, + message: format!("Network error: {}", e), + })?; let status = response.status(); if status != 200 { @@ -86,14 +79,10 @@ impl GithubClient { } async fn fetch_text(&self, url: &str) -> Result { - let response = self - .build_request(url) - .send() - .await - .map_err(|e| ApiError { - status: 0, - message: format!("Network error: {}", e), - })?; + let response = self.build_request(url).send().await.map_err(|e| ApiError { + status: 0, + message: format!("Network error: {}", e), + })?; let status = response.status(); if status != 200 { @@ -145,15 +134,13 @@ impl GithubClient { match content.content { Some(encoded) => { - let cleaned = encoded.replace('\n', "").replace('\r', ""); - let decoded = base64::Engine::decode( - &base64::engine::general_purpose::STANDARD, - &cleaned, - ) - .map_err(|e| ApiError { - status: 0, - message: format!("Base64 decode error: {}", e), - })?; + let cleaned = encoded.replace(['\n', '\r'], ""); + let decoded = + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &cleaned) + .map_err(|e| ApiError { + status: 0, + message: format!("Base64 decode error: {}", e), + })?; String::from_utf8(decoded).map_err(|e| ApiError { status: 0, message: format!("UTF-8 decode error: {}", e), @@ -219,11 +206,7 @@ impl GithubClient { } /// Check if a file exists in the repo - pub async fn file_exists( - &self, - repo: &RepoIdentifier, - path: &str, - ) -> bool { + pub async fn file_exists(&self, repo: &RepoIdentifier, path: &str) -> bool { let url = format!( "{}/repos/{}/{}/contents/{}", GITHUB_API_BASE, repo.owner, repo.repo, path @@ -292,8 +275,7 @@ mod tests { #[test] fn test_parse_trailing_slash() { - let result = - GithubClient::parse_repo_url("https://github.com/owner/repo/").unwrap(); + let result = GithubClient::parse_repo_url("https://github.com/owner/repo/").unwrap(); assert_eq!(result.owner, "owner"); assert_eq!(result.repo, "repo"); }