1use 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
18pub 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 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 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 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 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 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 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 if let Some(files) = Self::try_git_diff("HEAD^..HEAD") {
236 debug!("Using HEAD^ comparison");
237 return Ok(files);
238 }
239
240 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 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 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 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 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 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 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 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 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 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 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}