Skip to main content

lechange_core/http/
workflows.rs

1//! GitHub Actions Workflow API client
2
3use crate::error::{Error, Result};
4use crate::interner::StringInterner;
5use crate::types::{InternedString, WorkflowConclusion, WorkflowJob, WorkflowRun, WorkflowStatus};
6use serde::Deserialize;
7
8/// GitHub API response for workflow runs list
9#[derive(Debug, Deserialize)]
10struct WorkflowRunsResponse {
11    workflow_runs: Vec<GitHubWorkflowRun>,
12}
13
14/// GitHub API workflow run object
15#[derive(Debug, Deserialize)]
16struct GitHubWorkflowRun {
17    id: u64,
18    name: String,
19    status: String,
20    conclusion: Option<String>,
21    head_branch: String,
22    head_sha: String,
23    created_at: String,
24}
25
26/// GitHub API response for workflow jobs list
27#[derive(Debug, Deserialize)]
28struct WorkflowJobsResponse {
29    jobs: Vec<GitHubWorkflowJob>,
30}
31
32/// GitHub API job object
33#[derive(Debug, Deserialize)]
34struct GitHubWorkflowJob {
35    id: u64,
36    name: String,
37    status: String,
38    conclusion: Option<String>,
39    started_at: Option<String>,
40    completed_at: Option<String>,
41}
42
43/// GitHub API response for commit details
44#[derive(Debug, Deserialize)]
45struct GitHubCommit {
46    files: Option<Vec<GitHubCommitFile>>,
47}
48
49#[derive(Debug, Deserialize)]
50struct GitHubCommitFile {
51    filename: String,
52}
53
54/// Parse workflow status string to enum
55fn parse_status(s: &str) -> WorkflowStatus {
56    match s {
57        "queued" => WorkflowStatus::Queued,
58        "in_progress" => WorkflowStatus::InProgress,
59        _ => WorkflowStatus::Completed,
60    }
61}
62
63/// Parse workflow conclusion string to enum
64fn parse_conclusion(s: &str) -> WorkflowConclusion {
65    match s {
66        "success" => WorkflowConclusion::Success,
67        "failure" => WorkflowConclusion::Failure,
68        "cancelled" => WorkflowConclusion::Cancelled,
69        "skipped" => WorkflowConclusion::Skipped,
70        "timed_out" => WorkflowConclusion::TimedOut,
71        _ => WorkflowConclusion::Neutral,
72    }
73}
74
75/// GitHub Actions workflow API client
76pub struct WorkflowApiClient {
77    client: reqwest::Client,
78    base_url: String,
79    token: Option<String>,
80}
81
82impl std::fmt::Debug for WorkflowApiClient {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("WorkflowApiClient")
85            .field("base_url", &self.base_url)
86            .field("token", &self.token.as_ref().map(|_| "<redacted>"))
87            .finish_non_exhaustive()
88    }
89}
90
91impl WorkflowApiClient {
92    /// Create new workflow API client
93    pub fn new(base_url: String, token: Option<String>) -> Self {
94        let client = reqwest::Client::builder()
95            .user_agent("lechange/0.1.0")
96            .timeout(std::time::Duration::from_secs(30))
97            .build()
98            .unwrap_or_else(|_| reqwest::Client::new());
99
100        Self {
101            client,
102            base_url,
103            token,
104        }
105    }
106
107    /// Create from environment variables
108    pub fn from_env() -> Result<Self> {
109        let base_url = std::env::var("GITHUB_API_URL")
110            .unwrap_or_else(|_| "https://api.github.com".to_string());
111
112        let token = std::env::var("GITHUB_TOKEN").ok();
113
114        Ok(Self::new(base_url, token))
115    }
116
117    /// List workflow runs for a branch with filtering
118    ///
119    /// Endpoint: GET /repos/{owner}/{repo}/actions/runs
120    /// Query params: branch, status, per_page, page
121    #[allow(clippy::too_many_arguments)]
122    pub async fn list_workflow_runs(
123        &self,
124        owner: &str,
125        repo: &str,
126        branch: &str,
127        status: Option<&str>,
128        per_page: u32,
129        page: u32,
130        interner: &StringInterner,
131    ) -> Result<Vec<WorkflowRun>> {
132        let url = format!("{}/repos/{}/{}/actions/runs", self.base_url, owner, repo);
133
134        let mut request = self.client.get(&url).query(&[
135            ("per_page", per_page.to_string().as_str()),
136            ("page", page.to_string().as_str()),
137        ]);
138
139        // Add branch filter if not empty (empty = all branches)
140        if !branch.is_empty() {
141            request = request.query(&[("branch", branch)]);
142        }
143
144        if let Some(status_filter) = status {
145            request = request.query(&[("status", status_filter)]);
146        }
147
148        if let Some(ref token) = self.token {
149            request = request.header("Authorization", format!("Bearer {}", token));
150        }
151
152        let response = request
153            .send()
154            .await
155            .map_err(|e| Error::Workflow(format!("Failed to fetch workflow runs: {}", e)))?;
156
157        // Check for rate limiting
158        if response.status() == reqwest::StatusCode::FORBIDDEN {
159            let remaining = response
160                .headers()
161                .get("x-ratelimit-remaining")
162                .and_then(|v| v.to_str().ok())
163                .unwrap_or("0");
164
165            return Err(Error::RateLimitExceeded(format!(
166                "GitHub API rate limit exceeded. Remaining: {}. Consider using GITHUB_TOKEN.",
167                remaining
168            )));
169        }
170
171        if !response.status().is_success() {
172            return Err(Error::Workflow(format!(
173                "GitHub API returned error: {}",
174                response.status()
175            )));
176        }
177
178        let runs_response: WorkflowRunsResponse = response.json().await.map_err(|e| {
179            Error::Workflow(format!("Failed to parse workflow runs response: {}", e))
180        })?;
181
182        // Convert to our types
183        Ok(runs_response
184            .workflow_runs
185            .into_iter()
186            .map(|run| self.convert_workflow_run(run, interner))
187            .collect())
188    }
189
190    /// Get files changed in a specific commit
191    ///
192    /// Endpoint: GET /repos/{owner}/{repo}/commits/{sha}
193    /// Handles pagination for commits with >300 files
194    pub async fn get_commit_files(
195        &self,
196        owner: &str,
197        repo: &str,
198        sha: &str,
199        interner: &StringInterner,
200    ) -> Result<Vec<InternedString>> {
201        let url = format!("{}/repos/{}/{}/commits/{}", self.base_url, owner, repo, sha);
202
203        let mut all_files = Vec::new();
204        let mut page = 1;
205
206        loop {
207            let mut request = self
208                .client
209                .get(&url)
210                .query(&[("per_page", "100"), ("page", &page.to_string())]);
211
212            if let Some(ref token) = self.token {
213                request = request.header("Authorization", format!("Bearer {}", token));
214            }
215
216            let response = request
217                .send()
218                .await
219                .map_err(|e| Error::Workflow(format!("Failed to fetch commit: {}", e)))?;
220
221            if !response.status().is_success() {
222                return Err(Error::Workflow(format!(
223                    "GitHub API returned error for commit: {}",
224                    response.status()
225                )));
226            }
227
228            // Check for pagination via Link header
229            let has_next = response
230                .headers()
231                .get("Link")
232                .and_then(|v| v.to_str().ok())
233                .map(|link| link.contains("rel=\"next\""))
234                .unwrap_or(false);
235
236            let commit: GitHubCommit = response
237                .json()
238                .await
239                .map_err(|e| Error::Workflow(format!("Failed to parse commit response: {}", e)))?;
240
241            // Extract file paths
242            if let Some(files) = commit.files {
243                all_files.extend(files.into_iter().map(|f| interner.intern(&f.filename)));
244            }
245
246            if !has_next {
247                break;
248            }
249
250            page += 1;
251
252            // Safety limit
253            if page > 100 {
254                return Err(Error::Workflow(
255                    "Commit has too many files (>10000)".to_string(),
256                ));
257            }
258        }
259
260        Ok(all_files)
261    }
262
263    /// Get a specific workflow run
264    ///
265    /// Endpoint: GET /repos/{owner}/{repo}/actions/runs/{run_id}
266    pub async fn get_workflow_run(
267        &self,
268        owner: &str,
269        repo: &str,
270        run_id: u64,
271        interner: &StringInterner,
272    ) -> Result<WorkflowRun> {
273        let url = format!(
274            "{}/repos/{}/{}/actions/runs/{}",
275            self.base_url, owner, repo, run_id
276        );
277
278        let mut request = self.client.get(&url);
279
280        if let Some(ref token) = self.token {
281            request = request.header("Authorization", format!("Bearer {}", token));
282        }
283
284        let response = request
285            .send()
286            .await
287            .map_err(|e| Error::Workflow(format!("Failed to fetch workflow run: {}", e)))?;
288
289        if !response.status().is_success() {
290            return Err(Error::Workflow(format!(
291                "GitHub API error checking workflow: {}",
292                response.status()
293            )));
294        }
295
296        let run: GitHubWorkflowRun = response
297            .json()
298            .await
299            .map_err(|e| Error::Workflow(format!("Failed to parse workflow run: {}", e)))?;
300
301        Ok(self.convert_workflow_run(run, interner))
302    }
303
304    /// Wait for a specific workflow to complete with exponential backoff
305    ///
306    /// Returns when workflow completes or timeout is reached
307    pub async fn wait_for_workflow(
308        &self,
309        owner: &str,
310        repo: &str,
311        run_id: u64,
312        max_wait_seconds: u32,
313        interner: &StringInterner,
314    ) -> Result<WorkflowRun> {
315        let start = std::time::Instant::now();
316        let mut backoff_ms = 1000u64; // Start at 1 second
317        const MAX_BACKOFF_MS: u64 = 30000; // Cap at 30 seconds
318
319        loop {
320            // Check timeout
321            if start.elapsed().as_secs() >= max_wait_seconds as u64 {
322                return Err(Error::WorkflowTimeout(format!(
323                    "Workflow {} did not complete within {} seconds. Consider increasing workflow_max_wait_seconds.",
324                    run_id, max_wait_seconds
325                )));
326            }
327
328            // Fetch current workflow status
329            let workflow_run = self.get_workflow_run(owner, repo, run_id, interner).await?;
330
331            // Check if completed
332            if workflow_run.status == WorkflowStatus::Completed {
333                return Ok(workflow_run);
334            }
335
336            // Exponential backoff
337            tokio::time::sleep(tokio::time::Duration::from_millis(backoff_ms)).await;
338            backoff_ms = (backoff_ms * 2).min(MAX_BACKOFF_MS);
339        }
340    }
341
342    /// List jobs for a workflow run
343    ///
344    /// Endpoint: GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs
345    pub async fn list_workflow_jobs(
346        &self,
347        owner: &str,
348        repo: &str,
349        run_id: u64,
350        interner: &StringInterner,
351    ) -> Result<Vec<WorkflowJob>> {
352        let url = format!(
353            "{}/repos/{}/{}/actions/runs/{}/jobs",
354            self.base_url, owner, repo, run_id
355        );
356
357        let mut request = self.client.get(&url).query(&[("per_page", "100")]);
358
359        if let Some(ref token) = self.token {
360            request = request.header("Authorization", format!("Bearer {}", token));
361        }
362
363        let response = request
364            .send()
365            .await
366            .map_err(|e| Error::Workflow(format!("Failed to fetch workflow jobs: {}", e)))?;
367
368        if !response.status().is_success() {
369            return Err(Error::Workflow(format!(
370                "GitHub API error fetching jobs: {}",
371                response.status()
372            )));
373        }
374
375        let jobs_response: WorkflowJobsResponse = response.json().await.map_err(|e| {
376            Error::Workflow(format!("Failed to parse workflow jobs response: {}", e))
377        })?;
378
379        Ok(jobs_response
380            .jobs
381            .into_iter()
382            .map(|job| self.convert_workflow_job(job, run_id, interner))
383            .collect())
384    }
385
386    /// Convert GitHub API job to our type
387    fn convert_workflow_job(
388        &self,
389        job: GitHubWorkflowJob,
390        run_id: u64,
391        interner: &StringInterner,
392    ) -> WorkflowJob {
393        let status = parse_status(&job.status);
394        let conclusion = job.conclusion.as_ref().map(|c| parse_conclusion(c));
395
396        let started_at = job
397            .started_at
398            .as_ref()
399            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
400            .map(|dt| dt.timestamp())
401            .unwrap_or(0);
402
403        let completed_at = job
404            .completed_at
405            .as_ref()
406            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
407            .map(|dt| dt.timestamp())
408            .unwrap_or(0);
409
410        WorkflowJob {
411            id: job.id,
412            name: interner.intern(&job.name),
413            status,
414            conclusion,
415            run_id,
416            started_at,
417            completed_at,
418        }
419    }
420
421    /// Convert GitHub API workflow run to our type
422    fn convert_workflow_run(
423        &self,
424        run: GitHubWorkflowRun,
425        interner: &StringInterner,
426    ) -> WorkflowRun {
427        let status = parse_status(&run.status);
428        let conclusion = run.conclusion.as_ref().map(|c| parse_conclusion(c));
429
430        // Parse ISO 8601 timestamp to Unix epoch
431        let created_at = chrono::DateTime::parse_from_rfc3339(&run.created_at)
432            .map(|dt| dt.timestamp())
433            .unwrap_or(0);
434
435        WorkflowRun {
436            id: run.id,
437            name: interner.intern(&run.name),
438            status,
439            conclusion,
440            branch: interner.intern(&run.head_branch),
441            head_sha: interner.intern(&run.head_sha),
442            created_at,
443        }
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn test_workflow_api_client_creation() {
453        let client = WorkflowApiClient::new("https://api.github.com".to_string(), None);
454        assert_eq!(client.base_url, "https://api.github.com");
455        assert!(client.token.is_none());
456    }
457
458    #[test]
459    fn test_workflow_api_client_with_token() {
460        let client = WorkflowApiClient::new(
461            "https://api.github.com".to_string(),
462            Some("test_token".to_string()),
463        );
464        assert_eq!(client.token, Some("test_token".to_string()));
465    }
466
467    #[test]
468    fn test_workflow_status_conversion() {
469        let interner = StringInterner::new();
470        let client = WorkflowApiClient::new("https://api.github.com".to_string(), None);
471
472        let run = GitHubWorkflowRun {
473            id: 123,
474            name: "CI".to_string(),
475            status: "queued".to_string(),
476            conclusion: None,
477            head_branch: "main".to_string(),
478            head_sha: "abc123".to_string(),
479            created_at: "2024-01-01T00:00:00Z".to_string(),
480        };
481
482        let converted = client.convert_workflow_run(run, &interner);
483        assert_eq!(converted.status, WorkflowStatus::Queued);
484        assert_eq!(converted.conclusion, None);
485    }
486
487    #[test]
488    fn test_workflow_conclusion_conversion() {
489        let interner = StringInterner::new();
490        let client = WorkflowApiClient::new("https://api.github.com".to_string(), None);
491
492        let run = GitHubWorkflowRun {
493            id: 456,
494            name: "Test".to_string(),
495            status: "completed".to_string(),
496            conclusion: Some("failure".to_string()),
497            head_branch: "feature".to_string(),
498            head_sha: "def456".to_string(),
499            created_at: "2024-01-01T12:00:00Z".to_string(),
500        };
501
502        let converted = client.convert_workflow_run(run, &interner);
503        assert_eq!(converted.status, WorkflowStatus::Completed);
504        assert_eq!(converted.conclusion, Some(WorkflowConclusion::Failure));
505    }
506
507    #[test]
508    fn test_convert_workflow_job_success() {
509        let interner = StringInterner::new();
510        let client = WorkflowApiClient::new("https://api.github.com".to_string(), None);
511
512        let job = GitHubWorkflowJob {
513            id: 100,
514            name: "build".to_string(),
515            status: "completed".to_string(),
516            conclusion: Some("success".to_string()),
517            started_at: Some("2024-01-01T10:00:00Z".to_string()),
518            completed_at: Some("2024-01-01T10:05:00Z".to_string()),
519        };
520
521        let converted = client.convert_workflow_job(job, 1, &interner);
522        assert_eq!(converted.id, 100);
523        assert_eq!(converted.status, WorkflowStatus::Completed);
524        assert_eq!(converted.conclusion, Some(WorkflowConclusion::Success));
525        assert_eq!(converted.run_id, 1);
526        assert!(converted.started_at > 0);
527        assert!(converted.completed_at > 0);
528        assert!(converted.completed_at > converted.started_at);
529        assert_eq!(interner.resolve(converted.name), Some("build"));
530    }
531
532    #[test]
533    fn test_convert_workflow_job_failure() {
534        let interner = StringInterner::new();
535        let client = WorkflowApiClient::new("https://api.github.com".to_string(), None);
536
537        let job = GitHubWorkflowJob {
538            id: 200,
539            name: "test".to_string(),
540            status: "completed".to_string(),
541            conclusion: Some("failure".to_string()),
542            started_at: Some("2024-01-01T10:00:00Z".to_string()),
543            completed_at: Some("2024-01-01T10:03:00Z".to_string()),
544        };
545
546        let converted = client.convert_workflow_job(job, 2, &interner);
547        assert_eq!(converted.conclusion, Some(WorkflowConclusion::Failure));
548    }
549
550    #[test]
551    fn test_parse_status_all_variants() {
552        assert_eq!(parse_status("queued"), WorkflowStatus::Queued);
553        assert_eq!(parse_status("in_progress"), WorkflowStatus::InProgress);
554        assert_eq!(parse_status("completed"), WorkflowStatus::Completed);
555        // Unknown strings default to Completed
556        assert_eq!(parse_status("waiting"), WorkflowStatus::Completed);
557        assert_eq!(parse_status(""), WorkflowStatus::Completed);
558        assert_eq!(parse_status("QUEUED"), WorkflowStatus::Completed); // case-sensitive
559    }
560
561    #[test]
562    fn test_parse_conclusion_all_variants() {
563        assert_eq!(parse_conclusion("success"), WorkflowConclusion::Success);
564        assert_eq!(parse_conclusion("failure"), WorkflowConclusion::Failure);
565        assert_eq!(parse_conclusion("cancelled"), WorkflowConclusion::Cancelled);
566        assert_eq!(parse_conclusion("skipped"), WorkflowConclusion::Skipped);
567        assert_eq!(parse_conclusion("timed_out"), WorkflowConclusion::TimedOut);
568        // Unknown strings default to Neutral
569        assert_eq!(parse_conclusion("neutral"), WorkflowConclusion::Neutral);
570        assert_eq!(
571            parse_conclusion("action_required"),
572            WorkflowConclusion::Neutral
573        );
574        assert_eq!(parse_conclusion(""), WorkflowConclusion::Neutral);
575        assert_eq!(parse_conclusion("SUCCESS"), WorkflowConclusion::Neutral); // case-sensitive
576    }
577
578    #[test]
579    fn test_convert_workflow_job_no_timestamps() {
580        let interner = StringInterner::new();
581        let client = WorkflowApiClient::new("https://api.github.com".to_string(), None);
582
583        let job = GitHubWorkflowJob {
584            id: 300,
585            name: "pending".to_string(),
586            status: "queued".to_string(),
587            conclusion: None,
588            started_at: None,
589            completed_at: None,
590        };
591
592        let converted = client.convert_workflow_job(job, 3, &interner);
593        assert_eq!(converted.started_at, 0);
594        assert_eq!(converted.completed_at, 0);
595        assert_eq!(converted.status, WorkflowStatus::Queued);
596        assert_eq!(converted.conclusion, None);
597    }
598
599    #[test]
600    fn test_workflow_client_debug_redacts_token() {
601        let client = WorkflowApiClient::new(
602            "https://api.github.com".to_string(),
603            Some("ghp_WorkflowSecret99".to_string()),
604        );
605        let debug_output = format!("{:?}", client);
606        assert!(
607            !debug_output.contains("ghp_WorkflowSecret99"),
608            "Debug output must not contain the actual token: {}",
609            debug_output
610        );
611        assert!(
612            debug_output.contains("<redacted>"),
613            "Debug output should show <redacted>: {}",
614            debug_output
615        );
616    }
617
618    #[test]
619    fn test_workflow_client_debug_no_token() {
620        let client = WorkflowApiClient::new("https://api.github.com".to_string(), None);
621        let debug_output = format!("{:?}", client);
622        assert!(!debug_output.contains("<redacted>"));
623        assert!(debug_output.contains("token: None"));
624    }
625}