Skip to main content

cuenv_github/
ci.rs

1//! GitHub Actions CI provider.
2//!
3//! This provider integrates with GitHub Actions to:
4//! - Detect changed files in PRs and pushes
5//! - Create and update check runs
6//! - Post PR comments with pipeline reports
7
8use async_trait::async_trait;
9use cuenv_ci::context::CIContext;
10use cuenv_ci::provider::CIProvider;
11use cuenv_ci::report::{CheckHandle, PipelineReport, PipelineStatus, markdown::generate_summary};
12use cuenv_core::Result;
13use octocrab::Octocrab;
14use std::path::PathBuf;
15use std::process::Command;
16use tracing::{debug, info, warn};
17
18/// GitHub Actions CI provider.
19///
20/// Provides CI integration for repositories hosted on GitHub using GitHub Actions.
21pub struct GitHubCIProvider {
22    context: CIContext,
23    token: String,
24    owner: String,
25    repo: String,
26    pr_number: Option<u64>,
27}
28
29const NULL_SHA: &str = "0000000000000000000000000000000000000000";
30
31impl GitHubCIProvider {
32    fn parse_repo(repo_str: &str) -> (String, String) {
33        let parts: Vec<&str> = repo_str.split('/').collect();
34        if parts.len() == 2 {
35            (parts[0].to_string(), parts[1].to_string())
36        } else {
37            (String::new(), String::new())
38        }
39    }
40
41    /// Extract PR number from `GITHUB_REF` (e.g., "refs/pull/123/merge" -> 123)
42    fn parse_pr_number(github_ref: &str) -> Option<u64> {
43        if github_ref.starts_with("refs/pull/") {
44            github_ref
45                .strip_prefix("refs/pull/")?
46                .split('/')
47                .next()?
48                .parse()
49                .ok()
50        } else {
51            None
52        }
53    }
54
55    fn is_shallow_clone() -> bool {
56        Command::new("git")
57            .args(["rev-parse", "--is-shallow-repository"])
58            .output()
59            .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
60            .unwrap_or(false)
61    }
62
63    fn fetch_ref(refspec: &str) -> bool {
64        debug!("Fetching ref: {refspec}");
65        Command::new("git")
66            .args(["fetch", "--depth=1", "origin", refspec])
67            .output()
68            .map(|o| o.status.success())
69            .unwrap_or(false)
70    }
71
72    fn get_before_sha() -> Option<String> {
73        std::env::var("GITHUB_BEFORE")
74            .ok()
75            .filter(|sha| sha != NULL_SHA && !sha.is_empty())
76    }
77
78    fn try_git_diff(range: &str) -> Option<Vec<PathBuf>> {
79        debug!("Trying git diff: {range}");
80        let output = Command::new("git")
81            .args(["diff", "--name-only", range])
82            .output()
83            .ok()?;
84
85        if !output.status.success() {
86            debug!(
87                "git diff failed: {}",
88                String::from_utf8_lossy(&output.stderr)
89            );
90            return None;
91        }
92
93        let stdout = String::from_utf8_lossy(&output.stdout);
94        Some(
95            stdout
96                .lines()
97                .filter(|line| !line.trim().is_empty())
98                .map(|line| PathBuf::from(line.trim()))
99                .collect(),
100        )
101    }
102
103    fn get_all_tracked_files() -> Vec<PathBuf> {
104        Command::new("git")
105            .args(["ls-files"])
106            .output()
107            .map(|o| {
108                String::from_utf8_lossy(&o.stdout)
109                    .lines()
110                    .filter(|line| !line.trim().is_empty())
111                    .map(|line| PathBuf::from(line.trim()))
112                    .collect()
113            })
114            .unwrap_or_default()
115    }
116
117    /// Create an octocrab instance authenticated with the GitHub token.
118    fn octocrab(&self) -> Result<Octocrab> {
119        if self.token.is_empty() {
120            return Err(cuenv_core::Error::configuration(
121                "GITHUB_TOKEN is not set or empty",
122            ));
123        }
124        Octocrab::builder()
125            .personal_token(self.token.clone())
126            .build()
127            .map_err(|e| {
128                cuenv_core::Error::configuration(format!("Failed to create GitHub client: {e}"))
129            })
130    }
131
132    /// Get changed files for a PR using the GitHub API.
133    ///
134    /// This is faster and more reliable than git diff for PRs, as it doesn't
135    /// require fetching git history. Works with shallow clones.
136    async fn get_pr_files_from_api(&self, pr_number: u64) -> Result<Vec<PathBuf>> {
137        debug!("Fetching PR files from GitHub API for PR #{pr_number}");
138        let octocrab = self.octocrab()?;
139
140        let page = octocrab
141            .pulls(&self.owner, &self.repo)
142            .list_files(pr_number)
143            .await
144            .map_err(|e| {
145                cuenv_core::Error::configuration(format!("Failed to get PR files from API: {e}"))
146            })?;
147
148        let files: Vec<PathBuf> = page
149            .items
150            .iter()
151            .map(|f| PathBuf::from(&f.filename))
152            .collect();
153
154        info!("Got {} changed files from GitHub API", files.len());
155        Ok(files)
156    }
157}
158
159#[async_trait]
160impl CIProvider for GitHubCIProvider {
161    fn detect() -> Option<Self> {
162        if std::env::var("GITHUB_ACTIONS").ok()? != "true" {
163            return None;
164        }
165
166        let repo_str = std::env::var("GITHUB_REPOSITORY").ok()?;
167        let (owner, repo) = Self::parse_repo(&repo_str);
168
169        let github_ref = std::env::var("GITHUB_REF").unwrap_or_default();
170        let pr_number = Self::parse_pr_number(&github_ref);
171
172        Some(Self {
173            context: CIContext {
174                provider: "github".to_string(),
175                event: std::env::var("GITHUB_EVENT_NAME").unwrap_or_default(),
176                ref_name: std::env::var("GITHUB_REF_NAME").unwrap_or_default(),
177                base_ref: std::env::var("GITHUB_BASE_REF").ok(),
178                sha: std::env::var("GITHUB_SHA").unwrap_or_default(),
179            },
180            token: std::env::var("GITHUB_TOKEN").unwrap_or_default(),
181            owner,
182            repo,
183            pr_number,
184        })
185    }
186
187    fn context(&self) -> &CIContext {
188        &self.context
189    }
190
191    async fn changed_files(&self) -> Result<Vec<PathBuf>> {
192        // Strategy 1: Pull Request - use GitHub API (fastest, no git history needed)
193        if let Some(pr_number) = self.pr_number {
194            debug!("PR #{pr_number} detected, using GitHub API for changed files");
195            match self.get_pr_files_from_api(pr_number).await {
196                Ok(files) => return Ok(files),
197                Err(e) => {
198                    warn!("Failed to get PR files from API: {e}. Falling back to git diff.");
199                }
200            }
201        }
202
203        let is_shallow = Self::is_shallow_clone();
204        debug!("Shallow clone detected: {is_shallow}");
205
206        // Strategy 2: Pull Request - use git diff with base_ref (fallback if API fails)
207        if let Some(base) = &self.context.base_ref
208            && !base.is_empty()
209        {
210            debug!("PR detected, base_ref: {base}");
211
212            if is_shallow {
213                Self::fetch_ref(base);
214            }
215
216            if let Some(files) = Self::try_git_diff(&format!("origin/{base}...HEAD")) {
217                return Ok(files);
218            }
219        }
220
221        // Strategy 3: Push event with valid GITHUB_BEFORE
222        if let Some(before_sha) = Self::get_before_sha() {
223            debug!("Push event detected, GITHUB_BEFORE: {before_sha}");
224
225            if is_shallow {
226                Self::fetch_ref(&before_sha);
227            }
228
229            if let Some(files) = Self::try_git_diff(&format!("{before_sha}..HEAD")) {
230                return Ok(files);
231            }
232        }
233
234        // Strategy 4: Try comparing against parent commit
235        if let Some(files) = Self::try_git_diff("HEAD^..HEAD") {
236            debug!("Using HEAD^ comparison");
237            return Ok(files);
238        }
239
240        // Strategy 5: Fall back to all tracked files
241        warn!(
242            "Could not determine changed files (shallow clone: {is_shallow}). \
243             Running all tasks. For better performance, consider: \
244             1) Set 'fetch-depth: 2' for push events, or \
245             2) This may be a new branch with no history to compare."
246        );
247
248        Ok(Self::get_all_tracked_files())
249    }
250
251    async fn create_check(&self, name: &str) -> Result<CheckHandle> {
252        let octocrab = self.octocrab()?;
253
254        let check_run = octocrab
255            .checks(&self.owner, &self.repo)
256            .create_check_run(name, &self.context.sha)
257            .status(octocrab::params::checks::CheckRunStatus::InProgress)
258            .send()
259            .await
260            .map_err(|e| {
261                cuenv_core::Error::configuration(format!("Failed to create check run: {e}"))
262            })?;
263
264        info!("Created check run: {} (id: {})", name, check_run.id);
265
266        Ok(CheckHandle {
267            id: check_run.id.to_string(),
268        })
269    }
270
271    async fn update_check(&self, handle: &CheckHandle, summary: &str) -> Result<()> {
272        let octocrab = self.octocrab()?;
273        let check_run_id: u64 = handle
274            .id
275            .parse()
276            .map_err(|_| cuenv_core::Error::configuration("Invalid check run ID"))?;
277
278        octocrab
279            .checks(&self.owner, &self.repo)
280            .update_check_run(check_run_id.into())
281            .output(octocrab::params::checks::CheckRunOutput {
282                title: "cuenv CI".to_string(),
283                summary: summary.to_string(),
284                text: None,
285                annotations: vec![],
286                images: vec![],
287            })
288            .send()
289            .await
290            .map_err(|e| {
291                cuenv_core::Error::configuration(format!("Failed to update check run: {e}"))
292            })?;
293
294        Ok(())
295    }
296
297    async fn complete_check(&self, handle: &CheckHandle, report: &PipelineReport) -> Result<()> {
298        let octocrab = self.octocrab()?;
299        let check_run_id: u64 = handle
300            .id
301            .parse()
302            .map_err(|_| cuenv_core::Error::configuration("Invalid check run ID"))?;
303
304        let conclusion = match report.status {
305            PipelineStatus::Success => octocrab::params::checks::CheckRunConclusion::Success,
306            PipelineStatus::Failed => octocrab::params::checks::CheckRunConclusion::Failure,
307            PipelineStatus::Partial | PipelineStatus::Pending => {
308                octocrab::params::checks::CheckRunConclusion::Neutral
309            }
310        };
311
312        let summary = generate_summary(report);
313
314        octocrab
315            .checks(&self.owner, &self.repo)
316            .update_check_run(check_run_id.into())
317            .status(octocrab::params::checks::CheckRunStatus::Completed)
318            .conclusion(conclusion)
319            .output(octocrab::params::checks::CheckRunOutput {
320                title: format!("cuenv: {}", report.project),
321                summary,
322                text: None,
323                annotations: vec![],
324                images: vec![],
325            })
326            .send()
327            .await
328            .map_err(|e| {
329                cuenv_core::Error::configuration(format!("Failed to complete check run: {e}"))
330            })?;
331
332        info!("Completed check run: {}", handle.id);
333
334        Ok(())
335    }
336
337    async fn upload_report(&self, report: &PipelineReport) -> Result<Option<String>> {
338        // Only post PR comments for pull_request events
339        let Some(pr_number) = self.pr_number else {
340            debug!("Not a PR event, skipping PR comment");
341            return Ok(None);
342        };
343
344        let octocrab = self.octocrab()?;
345        let summary = generate_summary(report);
346
347        let comment = octocrab
348            .issues(&self.owner, &self.repo)
349            .create_comment(pr_number, &summary)
350            .await
351            .map_err(|e| {
352                cuenv_core::Error::configuration(format!("Failed to post PR comment: {e}"))
353            })?;
354
355        info!("Posted PR comment: {}", comment.html_url);
356
357        Ok(Some(comment.html_url.to_string()))
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn test_parse_repo() {
367        let (owner, repo) = GitHubCIProvider::parse_repo("cuenv/cuenv");
368        assert_eq!(owner, "cuenv");
369        assert_eq!(repo, "cuenv");
370    }
371
372    #[test]
373    fn test_parse_repo_different_names() {
374        let (owner, repo) = GitHubCIProvider::parse_repo("organization/project-name");
375        assert_eq!(owner, "organization");
376        assert_eq!(repo, "project-name");
377    }
378
379    #[test]
380    fn test_parse_repo_invalid() {
381        let (owner, repo) = GitHubCIProvider::parse_repo("invalid");
382        assert_eq!(owner, "");
383        assert_eq!(repo, "");
384    }
385
386    #[test]
387    fn test_parse_repo_empty() {
388        let (owner, repo) = GitHubCIProvider::parse_repo("");
389        assert_eq!(owner, "");
390        assert_eq!(repo, "");
391    }
392
393    #[test]
394    fn test_parse_repo_too_many_parts() {
395        let (owner, repo) = GitHubCIProvider::parse_repo("a/b/c/d");
396        assert_eq!(owner, "");
397        assert_eq!(repo, "");
398    }
399
400    #[test]
401    fn test_null_sha_constant() {
402        assert_eq!(NULL_SHA.len(), 40);
403        assert!(NULL_SHA.chars().all(|c| c == '0'));
404    }
405
406    #[test]
407    fn test_parse_pr_number() {
408        assert_eq!(
409            GitHubCIProvider::parse_pr_number("refs/pull/123/merge"),
410            Some(123)
411        );
412        assert_eq!(
413            GitHubCIProvider::parse_pr_number("refs/pull/456/head"),
414            Some(456)
415        );
416        assert_eq!(GitHubCIProvider::parse_pr_number("refs/heads/main"), None);
417        assert_eq!(GitHubCIProvider::parse_pr_number("main"), None);
418    }
419
420    #[test]
421    fn test_parse_pr_number_large() {
422        assert_eq!(
423            GitHubCIProvider::parse_pr_number("refs/pull/999999/merge"),
424            Some(999_999)
425        );
426    }
427
428    #[test]
429    fn test_parse_pr_number_zero() {
430        // Edge case: PR number 0 (unlikely but possible in parsing)
431        assert_eq!(
432            GitHubCIProvider::parse_pr_number("refs/pull/0/merge"),
433            Some(0)
434        );
435    }
436
437    #[test]
438    fn test_parse_pr_number_empty() {
439        assert_eq!(GitHubCIProvider::parse_pr_number(""), None);
440    }
441
442    #[test]
443    fn test_parse_pr_number_refs_pull_only() {
444        assert_eq!(GitHubCIProvider::parse_pr_number("refs/pull/"), None);
445    }
446
447    #[test]
448    fn test_parse_pr_number_invalid_number() {
449        assert_eq!(
450            GitHubCIProvider::parse_pr_number("refs/pull/abc/merge"),
451            None
452        );
453    }
454
455    #[test]
456    fn test_parse_pr_number_branch_ref() {
457        // Typical branch refs should return None
458        assert_eq!(
459            GitHubCIProvider::parse_pr_number("refs/heads/feature/test"),
460            None
461        );
462        assert_eq!(
463            GitHubCIProvider::parse_pr_number("refs/heads/develop"),
464            None
465        );
466        assert_eq!(GitHubCIProvider::parse_pr_number("refs/tags/v1.0.0"), None);
467    }
468
469    #[test]
470    fn test_get_before_sha_filters_null_sha() {
471        // The NULL_SHA should be filtered out
472        temp_env::with_var("GITHUB_BEFORE", Some(NULL_SHA), || {
473            assert!(GitHubCIProvider::get_before_sha().is_none());
474        });
475    }
476
477    #[test]
478    fn test_get_before_sha_filters_empty() {
479        // Empty string should be filtered out
480        temp_env::with_var("GITHUB_BEFORE", Some(""), || {
481            assert!(GitHubCIProvider::get_before_sha().is_none());
482        });
483    }
484
485    #[test]
486    fn test_get_before_sha_valid() {
487        // A valid SHA should be returned
488        let valid_sha = "abc123def456";
489        temp_env::with_var("GITHUB_BEFORE", Some(valid_sha), || {
490            assert_eq!(
491                GitHubCIProvider::get_before_sha(),
492                Some(valid_sha.to_string())
493            );
494        });
495    }
496
497    #[test]
498    fn test_detect_not_github_actions() {
499        // Clear GitHub Actions environment variables
500        temp_env::with_vars_unset(["GITHUB_ACTIONS", "GITHUB_REPOSITORY"], || {
501            let provider = GitHubCIProvider::detect();
502            assert!(provider.is_none());
503        });
504    }
505
506    #[test]
507    fn test_detect_github_actions_false() {
508        temp_env::with_var("GITHUB_ACTIONS", Some("false"), || {
509            let provider = GitHubCIProvider::detect();
510            assert!(provider.is_none());
511        });
512    }
513
514    #[test]
515    fn test_try_git_diff_parses_output() {
516        // This test just verifies the diff output parsing logic
517        // In a real repo, this would test actual git diff output
518
519        // Test that empty output results in empty vec
520        // This is implicitly tested through the filter logic
521        let empty_lines = "";
522        let files: Vec<PathBuf> = empty_lines
523            .lines()
524            .filter(|line| !line.trim().is_empty())
525            .map(|line| PathBuf::from(line.trim()))
526            .collect();
527        assert!(files.is_empty());
528
529        // Test that whitespace-only lines are filtered
530        let whitespace_only = "   \n\t\n";
531        let files: Vec<PathBuf> = whitespace_only
532            .lines()
533            .filter(|line| !line.trim().is_empty())
534            .map(|line| PathBuf::from(line.trim()))
535            .collect();
536        assert!(files.is_empty());
537
538        // Test that valid file paths are parsed
539        let valid_output = "src/main.rs\nCargo.toml\nREADME.md";
540        let files: Vec<PathBuf> = valid_output
541            .lines()
542            .filter(|line| !line.trim().is_empty())
543            .map(|line| PathBuf::from(line.trim()))
544            .collect();
545        assert_eq!(files.len(), 3);
546        assert_eq!(files[0], PathBuf::from("src/main.rs"));
547        assert_eq!(files[1], PathBuf::from("Cargo.toml"));
548        assert_eq!(files[2], PathBuf::from("README.md"));
549    }
550}