1use async_trait::async_trait;
7use cuenv_ci::context::CIContext;
8use cuenv_ci::provider::CIProvider;
9use cuenv_ci::report::{CheckHandle, PipelineReport, PipelineStatus};
10use cuenv_core::Result;
11use std::path::PathBuf;
12use std::process::Command;
13use tracing::{debug, info, warn};
14
15pub struct BuildkiteCIProvider {
21 context: CIContext,
22}
23
24impl BuildkiteCIProvider {
25 fn get_base_ref() -> Option<String> {
30 if let Ok(base) = std::env::var("BUILDKITE_PULL_REQUEST_BASE_BRANCH")
32 && !base.is_empty()
33 && base != "false"
34 {
35 return Some(base);
36 }
37
38 std::env::var("BUILDKITE_PIPELINE_DEFAULT_BRANCH").ok()
40 }
41
42 fn fetch_ref(refspec: &str) -> bool {
44 debug!("Fetching ref: {refspec}");
45 Command::new("git")
46 .args(["fetch", "--depth=1", "origin", refspec])
47 .output()
48 .is_ok_and(|o| o.status.success())
49 }
50
51 fn is_shallow_clone() -> bool {
53 Command::new("git")
54 .args(["rev-parse", "--is-shallow-repository"])
55 .output()
56 .is_ok_and(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
57 }
58
59 fn try_git_diff(range: &str) -> Option<Vec<PathBuf>> {
61 debug!("Trying git diff: {range}");
62 let output = Command::new("git")
63 .args(["diff", "--name-only", range])
64 .output()
65 .ok()?;
66
67 if !output.status.success() {
68 debug!(
69 "git diff failed: {}",
70 String::from_utf8_lossy(&output.stderr)
71 );
72 return None;
73 }
74
75 let stdout = String::from_utf8_lossy(&output.stdout);
76 Some(
77 stdout
78 .lines()
79 .filter(|line| !line.trim().is_empty())
80 .map(|line| PathBuf::from(line.trim()))
81 .collect(),
82 )
83 }
84
85 fn get_all_tracked_files() -> Vec<PathBuf> {
87 Command::new("git")
88 .args(["ls-files"])
89 .output()
90 .map(|o| {
91 String::from_utf8_lossy(&o.stdout)
92 .lines()
93 .filter(|line| !line.trim().is_empty())
94 .map(|line| PathBuf::from(line.trim()))
95 .collect()
96 })
97 .unwrap_or_default()
98 }
99}
100
101#[async_trait]
102impl CIProvider for BuildkiteCIProvider {
103 fn detect() -> Option<Self> {
104 if std::env::var("BUILDKITE").ok()? != "true" {
106 return None;
107 }
108
109 let event = std::env::var("BUILDKITE_SOURCE").unwrap_or_else(|_| "unknown".to_string());
110 let ref_name = std::env::var("BUILDKITE_BRANCH").unwrap_or_default();
111 let sha = std::env::var("BUILDKITE_COMMIT").unwrap_or_else(|_| "HEAD".to_string());
112 let base_ref = Self::get_base_ref();
113
114 info!(
115 "Detected Buildkite CI: branch={}, sha={}, base_ref={:?}",
116 ref_name, sha, base_ref
117 );
118
119 Some(Self {
120 context: CIContext {
121 provider: "buildkite".to_string(),
122 event,
123 ref_name,
124 base_ref,
125 sha,
126 },
127 })
128 }
129
130 fn context(&self) -> &CIContext {
131 &self.context
132 }
133
134 async fn changed_files(&self) -> Result<Vec<PathBuf>> {
135 let is_shallow = Self::is_shallow_clone();
136 info!(
137 "Shallow clone: {is_shallow}, base_ref: {:?}",
138 self.context.base_ref
139 );
140
141 if let Some(base) = &self.context.base_ref
143 && !base.is_empty()
144 && base != &self.context.ref_name
145 {
146 info!("PR detected, comparing against base_ref: {base}");
147
148 if is_shallow {
149 Self::fetch_ref(base);
150 }
151
152 if let Some(files) = Self::try_git_diff(&format!("origin/{base}...HEAD")) {
153 info!("Found {} changed files via PR comparison", files.len());
154 return Ok(files);
155 }
156 }
157
158 if is_shallow {
160 info!("Shallow clone detected, fetching additional history for HEAD^ comparison");
161 let _ = Command::new("git").args(["fetch", "--deepen=1"]).output();
162 }
163
164 if let Some(files) = Self::try_git_diff("HEAD^..HEAD") {
166 info!("Found {} changed files via HEAD^ comparison", files.len());
167 return Ok(files);
168 }
169
170 warn!(
172 "Could not determine changed files (shallow clone: {is_shallow}). \
173 Running all tasks. For better performance, ensure fetch-depth > 1."
174 );
175
176 let files = Self::get_all_tracked_files();
177 info!("Falling back to all {} tracked files", files.len());
178 Ok(files)
179 }
180
181 async fn create_check(&self, name: &str) -> Result<CheckHandle> {
182 let annotation_context = format!("cuenv-{}", name.replace(' ', "-").to_lowercase());
185
186 info!(
187 "Creating Buildkite check annotation: {}",
188 annotation_context
189 );
190
191 Ok(CheckHandle {
192 id: annotation_context,
193 })
194 }
195
196 async fn update_check(&self, handle: &CheckHandle, summary: &str) -> Result<()> {
197 let _ = Command::new("buildkite-agent")
199 .args([
200 "annotate",
201 summary,
202 "--style",
203 "info",
204 "--context",
205 &handle.id,
206 ])
207 .output();
208
209 Ok(())
210 }
211
212 async fn complete_check(&self, handle: &CheckHandle, report: &PipelineReport) -> Result<()> {
213 let style = match report.status {
214 PipelineStatus::Success => "success",
215 PipelineStatus::Failed => "error",
216 PipelineStatus::Partial | PipelineStatus::Pending => "warning",
217 };
218
219 let summary = format!(
220 "## {} Pipeline: {:?}\n\nDuration: {}ms\n\nTasks: {}",
221 report.project,
222 report.status,
223 report.duration_ms.unwrap_or(0),
224 report.tasks.len()
225 );
226
227 let _ = Command::new("buildkite-agent")
228 .args([
229 "annotate",
230 &summary,
231 "--style",
232 style,
233 "--context",
234 &handle.id,
235 ])
236 .output();
237
238 info!("Completed Buildkite check: {} -> {}", handle.id, style);
239
240 Ok(())
241 }
242
243 async fn upload_report(&self, report: &PipelineReport) -> Result<Option<String>> {
244 let report_json = serde_json::to_string_pretty(report).unwrap_or_default();
246 let report_path = format!(".cuenv/reports/{}-report.json", report.pipeline);
247
248 if let Some(parent) = std::path::Path::new(&report_path).parent() {
249 let _ = std::fs::create_dir_all(parent);
250 }
251
252 if std::fs::write(&report_path, &report_json).is_ok() {
253 let _ = Command::new("buildkite-agent")
255 .args(["artifact", "upload", &report_path])
256 .output();
257
258 info!("Uploaded report artifact: {}", report_path);
259 return Ok(Some(report_path));
260 }
261
262 Ok(None)
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 #[test]
271 fn test_detect_not_buildkite() {
272 temp_env::with_var_unset("BUILDKITE", || {
274 assert!(BuildkiteCIProvider::detect().is_none());
275 });
276 }
277
278 #[test]
279 fn test_detect_buildkite() {
280 temp_env::with_vars(
281 [
282 ("BUILDKITE", Some("true")),
283 ("BUILDKITE_BRANCH", Some("main")),
284 ("BUILDKITE_COMMIT", Some("abc123")),
285 ("BUILDKITE_SOURCE", Some("webhook")),
286 ],
287 || {
288 let provider = BuildkiteCIProvider::detect();
289 assert!(provider.is_some());
290
291 let provider = provider.unwrap();
292 assert_eq!(provider.context.provider, "buildkite");
293 assert_eq!(provider.context.ref_name, "main");
294 assert_eq!(provider.context.sha, "abc123");
295 assert_eq!(provider.context.event, "webhook");
296 },
297 );
298 }
299
300 #[test]
301 fn test_detect_buildkite_pr() {
302 temp_env::with_vars(
303 [
304 ("BUILDKITE", Some("true")),
305 ("BUILDKITE_BRANCH", Some("feature/test")),
306 ("BUILDKITE_COMMIT", Some("def456")),
307 ("BUILDKITE_SOURCE", Some("webhook")),
308 ("BUILDKITE_PULL_REQUEST_BASE_BRANCH", Some("main")),
309 ],
310 || {
311 let provider = BuildkiteCIProvider::detect().unwrap();
312 assert_eq!(provider.context.base_ref, Some("main".to_string()));
313 },
314 );
315 }
316}