Skip to main content

cuenv_buildkite/
provider.rs

1//! Buildkite CI Provider
2//!
3//! Detects Buildkite CI environment and provides changed files detection
4//! using Buildkite environment variables.
5
6use 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
15/// Buildkite CI provider.
16///
17/// Provides CI integration for pipelines running on Buildkite.
18/// Detects context from Buildkite environment variables and uses
19/// git to determine changed files.
20pub struct BuildkiteCIProvider {
21    context: CIContext,
22}
23
24impl BuildkiteCIProvider {
25    /// Get the base ref for comparison.
26    ///
27    /// For pull requests, uses `BUILDKITE_PULL_REQUEST_BASE_BRANCH`.
28    /// For regular builds, attempts to use the default branch.
29    fn get_base_ref() -> Option<String> {
30        // For PRs, use the base branch
31        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        // Fall back to pipeline default branch if available
39        std::env::var("BUILDKITE_PIPELINE_DEFAULT_BRANCH").ok()
40    }
41
42    /// Fetch a ref if we're in a shallow clone.
43    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    /// Check if this is a shallow clone.
52    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    /// Try to get changed files using git diff.
60    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    /// Get all tracked files as fallback.
86    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        // Buildkite sets BUILDKITE=true
105        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        // Strategy 1: Pull Request - use base_ref (but not if same as current branch)
142        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        // Strategy 2: For shallow clones, fetch one more commit to enable HEAD^ comparison
159        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        // Strategy 3: Compare against parent commit
165        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        // Strategy 4: Fall back to all tracked files
171        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        // Buildkite uses annotations for status updates
183        // Create an annotation with the check name
184        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        // Update the annotation with progress
198        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        // Write report to a temp file and upload as artifact
245        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            // Upload as artifact
254            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        // Without BUILDKITE env var, should return None
273        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}