|
| 1 | +use std::hash::{DefaultHasher, Hash, Hasher}; |
| 2 | +use std::path::{Path, PathBuf}; |
| 3 | + |
| 4 | +#[cfg(windows)] |
| 5 | +use cow_utils::CowUtils; |
| 6 | + |
| 7 | +use serde::Serialize; |
| 8 | + |
| 9 | +use oxc_diagnostics::{ |
| 10 | + Error, Severity, |
| 11 | + reporter::{DiagnosticReporter, DiagnosticResult, Info}, |
| 12 | +}; |
| 13 | + |
| 14 | +use crate::output_formatter::InternalFormatter; |
| 15 | + |
| 16 | +#[derive(Debug, Default)] |
| 17 | +pub struct BitbucketOutputFormatter; |
| 18 | + |
| 19 | +/// Severity levels accepted by the Bitbucket Code Insights Annotations API. |
| 20 | +/// |
| 21 | +/// <https://developer.atlassian.com/cloud/bitbucket/rest/api-group-reports/#api-group-reports> |
| 22 | +#[derive(Debug, Serialize)] |
| 23 | +#[serde(rename_all = "UPPERCASE")] |
| 24 | +#[expect(dead_code)] |
| 25 | +enum BitbucketSeverity { |
| 26 | + Critical, |
| 27 | + High, |
| 28 | + Medium, |
| 29 | + Low, |
| 30 | +} |
| 31 | + |
| 32 | +/// Annotation types accepted by the Bitbucket Code Insights Annotations API. |
| 33 | +#[derive(Debug, Serialize)] |
| 34 | +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] |
| 35 | +#[expect(dead_code)] |
| 36 | +enum BitbucketAnnotationType { |
| 37 | + Bug, |
| 38 | + CodeSmell, |
| 39 | + Vulnerability, |
| 40 | +} |
| 41 | + |
| 42 | +/// A single Bitbucket Code Insights annotation entry. |
| 43 | +/// |
| 44 | +/// Matches the schema required by the bulk create/update annotations endpoint: |
| 45 | +/// `POST /repositories/{workspace}/{repo_slug}/commit/{commit}/reports/{reportId}/annotations` |
| 46 | +#[derive(Debug, Serialize)] |
| 47 | +struct BitbucketAnnotationJson { |
| 48 | + external_id: String, |
| 49 | + summary: String, |
| 50 | + #[serde(skip_serializing_if = "Option::is_none")] |
| 51 | + details: Option<String>, |
| 52 | + annotation_type: BitbucketAnnotationType, |
| 53 | + severity: BitbucketSeverity, |
| 54 | + path: String, |
| 55 | + line: usize, |
| 56 | +} |
| 57 | + |
| 58 | +impl InternalFormatter for BitbucketOutputFormatter { |
| 59 | + fn get_diagnostic_reporter(&self) -> Box<dyn DiagnosticReporter> { |
| 60 | + Box::new(BitbucketReporter::default()) |
| 61 | + } |
| 62 | +} |
| 63 | + |
| 64 | +/// Find the git repository root by walking up from the current directory. |
| 65 | +/// Returns `None` if no `.git` directory is found. |
| 66 | +fn find_git_root() -> Option<PathBuf> { |
| 67 | + let cwd = std::env::current_dir().ok()?; |
| 68 | + find_git_root_from(&cwd) |
| 69 | +} |
| 70 | + |
| 71 | +/// Find the git repository root by walking up from the given path. |
| 72 | +fn find_git_root_from(start: &Path) -> Option<PathBuf> { |
| 73 | + let mut current = start.to_path_buf(); |
| 74 | + loop { |
| 75 | + if current.join(".git").exists() { |
| 76 | + return Some(current); |
| 77 | + } |
| 78 | + if !current.pop() { |
| 79 | + return None; |
| 80 | + } |
| 81 | + } |
| 82 | +} |
| 83 | + |
| 84 | +/// Get the path prefix from CWD to the git repository root. |
| 85 | +/// This prefix should be prepended to CWD-relative paths to make them repo-relative. |
| 86 | +/// |
| 87 | +/// For example, if git root is `/repo` and CWD is `/repo/packages/foo`, |
| 88 | +/// this returns `Some("packages/foo")`. |
| 89 | +fn get_repo_path_prefix() -> Option<PathBuf> { |
| 90 | + let cwd = std::env::current_dir().ok()?; |
| 91 | + let git_root = find_git_root()?; |
| 92 | + |
| 93 | + let relative = cwd.strip_prefix(&git_root).ok()?; |
| 94 | + if relative.as_os_str().is_empty() { |
| 95 | + return None; |
| 96 | + } |
| 97 | + |
| 98 | + Some(relative.to_path_buf()) |
| 99 | +} |
| 100 | + |
| 101 | +/// Renders reports as a Bitbucket Code Insights annotations array. |
| 102 | +/// |
| 103 | +/// The output JSON array can be sent directly to the Bitbucket bulk annotations endpoint: |
| 104 | +/// `POST /repositories/{workspace}/{repo_slug}/commit/{commit}/reports/{reportId}/annotations` |
| 105 | +/// |
| 106 | +/// Note that, due to syntactic restrictions of JSON arrays, this reporter waits until all |
| 107 | +/// diagnostics have been reported before writing them to the output stream. |
| 108 | +struct BitbucketReporter { |
| 109 | + diagnostics: Vec<Error>, |
| 110 | + /// Path prefix to prepend to CWD-relative paths to make them repo-relative. |
| 111 | + /// `None` if CWD is the git root or if we're not in a git repository. |
| 112 | + repo_path_prefix: Option<PathBuf>, |
| 113 | +} |
| 114 | + |
| 115 | +impl BitbucketReporter { |
| 116 | + fn default() -> Self { |
| 117 | + Self { diagnostics: Vec::new(), repo_path_prefix: get_repo_path_prefix() } |
| 118 | + } |
| 119 | +} |
| 120 | + |
| 121 | +impl DiagnosticReporter for BitbucketReporter { |
| 122 | + fn finish(&mut self, _: &DiagnosticResult) -> Option<String> { |
| 123 | + Some(format_bitbucket(&mut self.diagnostics, self.repo_path_prefix.as_deref())) |
| 124 | + } |
| 125 | + |
| 126 | + fn render_error(&mut self, error: Error) -> Option<String> { |
| 127 | + self.diagnostics.push(error); |
| 128 | + None |
| 129 | + } |
| 130 | +} |
| 131 | + |
| 132 | +fn format_bitbucket(diagnostics: &mut Vec<Error>, repo_path_prefix: Option<&Path>) -> String { |
| 133 | + let annotations = diagnostics.drain(..).map(|error| { |
| 134 | + let Info { start, filename, message, severity, rule_id, .. } = Info::new(&error); |
| 135 | + |
| 136 | + let (annotation_type, bitbucket_severity) = match severity { |
| 137 | + Severity::Error => (BitbucketAnnotationType::Bug, BitbucketSeverity::High), |
| 138 | + Severity::Warning => (BitbucketAnnotationType::CodeSmell, BitbucketSeverity::Medium), |
| 139 | + Severity::Advice => (BitbucketAnnotationType::CodeSmell, BitbucketSeverity::Low), |
| 140 | + }; |
| 141 | + |
| 142 | + let external_id = { |
| 143 | + let mut hasher = DefaultHasher::new(); |
| 144 | + start.line.hash(&mut hasher); |
| 145 | + filename.hash(&mut hasher); |
| 146 | + message.hash(&mut hasher); |
| 147 | + rule_id.hash(&mut hasher); |
| 148 | + format!("oxlint-{:x}", hasher.finish()) |
| 149 | + }; |
| 150 | + |
| 151 | + let path = match repo_path_prefix { |
| 152 | + Some(prefix) => { |
| 153 | + // Normalize path separators to forward slashes on Windows. |
| 154 | + #[cfg(windows)] |
| 155 | + { |
| 156 | + let combined = prefix.join(&filename); |
| 157 | + combined.to_string_lossy().cow_replace('\\', "/").into_owned() |
| 158 | + } |
| 159 | + #[cfg(not(windows))] |
| 160 | + { |
| 161 | + prefix.join(&filename).to_string_lossy().to_string() |
| 162 | + } |
| 163 | + } |
| 164 | + None => filename, |
| 165 | + }; |
| 166 | + |
| 167 | + BitbucketAnnotationJson { |
| 168 | + external_id, |
| 169 | + summary: message, |
| 170 | + details: rule_id, |
| 171 | + annotation_type, |
| 172 | + severity: bitbucket_severity, |
| 173 | + path, |
| 174 | + line: start.line, |
| 175 | + } |
| 176 | + }); |
| 177 | + |
| 178 | + serde_json::to_string_pretty(&annotations.collect::<Vec<_>>()).expect("Failed to serialize") |
| 179 | +} |
| 180 | + |
| 181 | +#[cfg(test)] |
| 182 | +mod test { |
| 183 | + use std::path::{Path, PathBuf}; |
| 184 | + |
| 185 | + use oxc_diagnostics::{ |
| 186 | + Error, NamedSource, OxcDiagnostic, |
| 187 | + reporter::{DiagnosticReporter, DiagnosticResult}, |
| 188 | + }; |
| 189 | + use oxc_span::Span; |
| 190 | + |
| 191 | + use super::{BitbucketReporter, find_git_root_from, format_bitbucket}; |
| 192 | + |
| 193 | + #[test] |
| 194 | + fn reporter() { |
| 195 | + let mut reporter = BitbucketReporter::default(); |
| 196 | + |
| 197 | + let error = OxcDiagnostic::warn("error message") |
| 198 | + .with_label(Span::new(0, 8)) |
| 199 | + .with_source_code(NamedSource::new("test.ts", "debugger;")); |
| 200 | + |
| 201 | + let first_result = reporter.render_error(error); |
| 202 | + |
| 203 | + // reporter keeps it in memory |
| 204 | + assert!(first_result.is_none()); |
| 205 | + |
| 206 | + // reporter gives results when finishing |
| 207 | + let second_result = reporter.finish(&DiagnosticResult::default()); |
| 208 | + |
| 209 | + assert!(second_result.is_some()); |
| 210 | + let json: serde_json::Value = serde_json::from_str(&second_result.unwrap()).unwrap(); |
| 211 | + let array = json.as_array().unwrap(); |
| 212 | + assert_eq!(array.len(), 1); |
| 213 | + let value = array[0].as_object().unwrap(); |
| 214 | + assert!(value["external_id"].as_str().unwrap().starts_with("oxlint-")); |
| 215 | + assert_eq!(value["summary"], "error message"); |
| 216 | + assert_eq!(value["annotation_type"], "CODE_SMELL"); |
| 217 | + assert_eq!(value["severity"], "MEDIUM"); |
| 218 | + let location_path = value["path"].as_str().unwrap(); |
| 219 | + assert!( |
| 220 | + location_path.ends_with("test.ts"), |
| 221 | + "path '{location_path}' should end with test.ts" |
| 222 | + ); |
| 223 | + assert_eq!(value["line"], 1); |
| 224 | + } |
| 225 | + |
| 226 | + #[test] |
| 227 | + fn reporter_with_error_severity() { |
| 228 | + let mut reporter = BitbucketReporter { diagnostics: Vec::new(), repo_path_prefix: None }; |
| 229 | + |
| 230 | + let error = OxcDiagnostic::error("critical error") |
| 231 | + .with_label(Span::new(0, 8)) |
| 232 | + .with_source_code(NamedSource::new("example.js", "eval('x');")); |
| 233 | + |
| 234 | + reporter.render_error(error); |
| 235 | + let result = reporter.finish(&DiagnosticResult::default()).unwrap(); |
| 236 | + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); |
| 237 | + let value = &json[0]; |
| 238 | + assert_eq!(value["annotation_type"], "BUG"); |
| 239 | + assert_eq!(value["severity"], "HIGH"); |
| 240 | + assert_eq!(value["path"], "example.js"); |
| 241 | + } |
| 242 | + |
| 243 | + #[test] |
| 244 | + fn find_git_root_from_current_dir() { |
| 245 | + let cwd = std::env::current_dir().unwrap(); |
| 246 | + let git_root = find_git_root_from(&cwd); |
| 247 | + assert!(git_root.is_some()); |
| 248 | + assert!(git_root.unwrap().join(".git").exists()); |
| 249 | + } |
| 250 | + |
| 251 | + #[test] |
| 252 | + fn find_git_root_from_nonexistent() { |
| 253 | + let path = PathBuf::from("/"); |
| 254 | + let git_root = find_git_root_from(&path); |
| 255 | + assert!(git_root.is_none() || *git_root.unwrap() == *"/"); |
| 256 | + } |
| 257 | + |
| 258 | + #[test] |
| 259 | + fn format_bitbucket_with_prefix() { |
| 260 | + let error = OxcDiagnostic::warn("test error") |
| 261 | + .with_label(Span::new(0, 5)) |
| 262 | + .with_source_code(NamedSource::new("example.js", "const x = 1;")); |
| 263 | + |
| 264 | + let mut diagnostics: Vec<Error> = vec![error]; |
| 265 | + |
| 266 | + let result = format_bitbucket(&mut diagnostics, Some(Path::new("packages/foo"))); |
| 267 | + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); |
| 268 | + let path = json[0]["path"].as_str().unwrap(); |
| 269 | + assert_eq!(path, "packages/foo/example.js"); |
| 270 | + } |
| 271 | + |
| 272 | + #[test] |
| 273 | + fn format_bitbucket_without_prefix() { |
| 274 | + let error = OxcDiagnostic::warn("test error") |
| 275 | + .with_label(Span::new(0, 5)) |
| 276 | + .with_source_code(NamedSource::new("example.js", "const x = 1;")); |
| 277 | + |
| 278 | + let mut diagnostics: Vec<Error> = vec![error]; |
| 279 | + |
| 280 | + let result = format_bitbucket(&mut diagnostics, None); |
| 281 | + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); |
| 282 | + let path = json[0]["path"].as_str().unwrap(); |
| 283 | + assert_eq!(path, "example.js"); |
| 284 | + } |
| 285 | + |
| 286 | + #[test] |
| 287 | + fn format_bitbucket_external_id_uniqueness() { |
| 288 | + // Two identical errors should produce the same external_id (deterministic hashing) |
| 289 | + let error1 = OxcDiagnostic::warn("duplicate") |
| 290 | + .with_label(Span::new(0, 5)) |
| 291 | + .with_source_code(NamedSource::new("file.js", "const x = 1;")); |
| 292 | + let error2 = OxcDiagnostic::warn("duplicate") |
| 293 | + .with_label(Span::new(0, 5)) |
| 294 | + .with_source_code(NamedSource::new("file.js", "const x = 1;")); |
| 295 | + |
| 296 | + let mut d1: Vec<Error> = vec![error1]; |
| 297 | + let mut d2: Vec<Error> = vec![error2]; |
| 298 | + |
| 299 | + let r1 = format_bitbucket(&mut d1, None); |
| 300 | + let r2 = format_bitbucket(&mut d2, None); |
| 301 | + |
| 302 | + let j1: serde_json::Value = serde_json::from_str(&r1).unwrap(); |
| 303 | + let j2: serde_json::Value = serde_json::from_str(&r2).unwrap(); |
| 304 | + |
| 305 | + assert_eq!(j1[0]["external_id"], j2[0]["external_id"]); |
| 306 | + } |
| 307 | + |
| 308 | + #[cfg(windows)] |
| 309 | + #[test] |
| 310 | + fn format_bitbucket_windows_normalization() { |
| 311 | + let error = OxcDiagnostic::warn("test error") |
| 312 | + .with_label(Span::new(0, 5)) |
| 313 | + .with_source_code(NamedSource::new("example.js", "const x = 1;")); |
| 314 | + |
| 315 | + let mut diagnostics: Vec<Error> = vec![error]; |
| 316 | + |
| 317 | + // Windows-style prefix with backslashes should be normalized to forward slashes |
| 318 | + let result = format_bitbucket(&mut diagnostics, Some(Path::new(r"packages\foo"))); |
| 319 | + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); |
| 320 | + let path = json[0]["path"].as_str().unwrap(); |
| 321 | + assert_eq!(path, "packages/foo/example.js"); |
| 322 | + } |
| 323 | +} |
0 commit comments