Skip to content

Commit 0f50788

Browse files
committed
feat(oxlint): add --format bitbucket for Bitbucket Code Insights reports
1 parent 2af7084 commit 0f50788

10 files changed

+448
-9
lines changed

.agents/skills/migrate-oxlint/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ Additional oxlint options:
152152
- Disable comments work: `// eslint-disable` and `// eslint-disable-next-line` comments are supported by oxlint. Use `--replace-eslint-comments` to convert them to `// oxlint-disable` if desired.
153153
- List available rules: Run `npx oxlint@latest --rules` to see all supported rules.
154154
- Schema support: Add `"$schema": "./node_modules/oxlint/configuration_schema.json"` to `.oxlintrc.json` for editor autocompletion.
155-
- Output formats: `default`, `stylish`, `json`, `github`, `gitlab`, `junit`, `checkstyle`, `unix`
155+
- Output formats: `default`, `stylish`, `json`, `github`, `gitlab`, `bitbucket`, `junit`, `checkstyle`, `unix`
156156

157157
## References
158158

apps/oxlint/src/command/lint.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ pub struct WarningOptions {
244244
#[derive(Debug, Clone, Bpaf)]
245245
pub struct OutputOptions {
246246
/// Use a specific output format. Possible values:
247-
/// `checkstyle`, `default`, `github`, `gitlab`, `json`, `junit`, `stylish`, `unix`
247+
/// `bitbucket`, `checkstyle`, `default`, `github`, `gitlab`, `json`, `junit`, `stylish`, `unix`
248248
#[bpaf(long, short, fallback_with(default_output_format), hide_usage)]
249249
pub format: OutputFormat,
250250
}
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
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

Comments
 (0)