Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .githooks/commit-msg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
12 changes: 10 additions & 2 deletions src/checks/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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(),
})
}
}
124 changes: 55 additions & 69 deletions src/checks/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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() {
Expand All @@ -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<String> =
yaml_files.iter().map(|f| f.name.clone()).collect();
let names: Vec<String> = 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(", ")
),
)
}
}
Expand Down Expand Up @@ -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)",
),
}
}

Expand Down Expand Up @@ -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",
];
Expand All @@ -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",
)
}
Expand Down Expand Up @@ -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(", ")))
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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é")
Expand Down Expand Up @@ -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"),
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(", ")
),
);
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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');
}
Expand Down
6 changes: 1 addition & 5 deletions src/components/search_bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
})
Expand Down
12 changes: 2 additions & 10 deletions src/models/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,7 @@ impl CheckResult {
}
}

pub fn failed(
check: Check,
detail: impl Into<String>,
suggestion: impl Into<String>,
) -> Self {
pub fn failed(check: Check, detail: impl Into<String>, suggestion: impl Into<String>) -> Self {
Self {
check,
status: CheckStatus::Failed,
Expand All @@ -85,11 +81,7 @@ impl CheckResult {
}
}

pub fn warning(
check: Check,
detail: impl Into<String>,
suggestion: impl Into<String>,
) -> Self {
pub fn warning(check: Check, detail: impl Into<String>, suggestion: impl Into<String>) -> Self {
Self {
check,
status: CheckStatus::Warning,
Expand Down
Loading