|
14 | 14 |
|
15 | 15 | use crate::config::Config; |
16 | 16 | use crate::github::GitHubClient; |
| 17 | +use crate::rules::Violation; |
17 | 18 | use crate::rules::{RuleResult, check_labels, check_title}; |
18 | 19 |
|
19 | 20 | pub struct Engine { |
@@ -43,6 +44,75 @@ impl Engine { |
43 | 44 | all_violations.extend(violations); |
44 | 45 | } |
45 | 46 |
|
| 47 | + // Ensure title type aligns with kind/* label (best-effort) |
| 48 | + if let Some(expected) = expected_label_for_title(&pr.title) { |
| 49 | + if !has_label(&pr, expected) { |
| 50 | + all_violations.push(Violation { |
| 51 | + message: format!( |
| 52 | + "Title type '{}' requires label '{}', current labels: [{}], title: '{}'", |
| 53 | + title_type(&pr.title), |
| 54 | + expected, |
| 55 | + format_labels(&pr), |
| 56 | + pr.title |
| 57 | + ), |
| 58 | + }); |
| 59 | + } |
| 60 | + } |
| 61 | + |
| 62 | + // Prepend a context line with title and labels if there are violations |
| 63 | + if !all_violations.is_empty() { |
| 64 | + all_violations.insert( |
| 65 | + 0, |
| 66 | + Violation { |
| 67 | + message: format!( |
| 68 | + "Context -> title: '{}'; labels: [{}]", |
| 69 | + pr.title, |
| 70 | + format_labels(&pr) |
| 71 | + ), |
| 72 | + }, |
| 73 | + ); |
| 74 | + } |
| 75 | + |
46 | 76 | Ok(all_violations) |
47 | 77 | } |
48 | 78 | } |
| 79 | + |
| 80 | +fn has_label(pr: &crate::github::PullRequest, name: &str) -> bool { |
| 81 | + pr.labels.iter().any(|l| l.name == name) |
| 82 | +} |
| 83 | + |
| 84 | +fn format_labels(pr: &crate::github::PullRequest) -> String { |
| 85 | + if pr.labels.is_empty() { |
| 86 | + "none".to_string() |
| 87 | + } else { |
| 88 | + pr.labels |
| 89 | + .iter() |
| 90 | + .map(|l| l.name.clone()) |
| 91 | + .collect::<Vec<_>>() |
| 92 | + .join(", ") |
| 93 | + } |
| 94 | +} |
| 95 | + |
| 96 | +fn title_type(title: &str) -> String { |
| 97 | + let prefix = title.split(':').next().unwrap_or_default().trim(); |
| 98 | + // Support optional component scope, e.g., feat(api-server): ... |
| 99 | + let type_only = prefix.split('(').next().unwrap_or(prefix).trim(); |
| 100 | + type_only.to_lowercase() |
| 101 | +} |
| 102 | + |
| 103 | +fn expected_label_for_title(title: &str) -> Option<&'static str> { |
| 104 | + match title_type(title).as_str() { |
| 105 | + "feat" => Some("kind/feature"), |
| 106 | + "fix" => Some("kind/bug"), |
| 107 | + "docs" => Some("kind/docs"), |
| 108 | + "chore" => Some("kind/chore"), |
| 109 | + "refactor" => Some("kind/refactor"), |
| 110 | + "test" => Some("kind/test"), |
| 111 | + "perf" => Some("kind/performance"), |
| 112 | + "ci" => Some("kind/ci"), |
| 113 | + "build" => Some("kind/build"), |
| 114 | + "security" => Some("kind/security"), |
| 115 | + "dependencies" => Some("kind/dependencies"), |
| 116 | + _ => None, |
| 117 | + } |
| 118 | +} |
0 commit comments