1use crate::error::{Error, Result};
4use crate::interner::StringInterner;
5use crate::types::{InternedString, WorkflowConclusion, WorkflowJob, WorkflowRun, WorkflowStatus};
6use serde::Deserialize;
7
8#[derive(Debug, Deserialize)]
10struct WorkflowRunsResponse {
11 workflow_runs: Vec<GitHubWorkflowRun>,
12}
13
14#[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#[derive(Debug, Deserialize)]
28struct WorkflowJobsResponse {
29 jobs: Vec<GitHubWorkflowJob>,
30}
31
32#[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#[derive(Debug, Deserialize)]
45struct GitHubCommit {
46 files: Option<Vec<GitHubCommitFile>>,
47}
48
49#[derive(Debug, Deserialize)]
50struct GitHubCommitFile {
51 filename: String,
52}
53
54fn parse_status(s: &str) -> WorkflowStatus {
56 match s {
57 "queued" => WorkflowStatus::Queued,
58 "in_progress" => WorkflowStatus::InProgress,
59 _ => WorkflowStatus::Completed,
60 }
61}
62
63fn 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
75pub 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 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 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 #[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 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 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 Ok(runs_response
184 .workflow_runs
185 .into_iter()
186 .map(|run| self.convert_workflow_run(run, interner))
187 .collect())
188 }
189
190 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 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 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 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 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 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; const MAX_BACKOFF_MS: u64 = 30000; loop {
320 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 let workflow_run = self.get_workflow_run(owner, repo, run_id, interner).await?;
330
331 if workflow_run.status == WorkflowStatus::Completed {
333 return Ok(workflow_run);
334 }
335
336 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 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 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 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 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 assert_eq!(parse_status("waiting"), WorkflowStatus::Completed);
557 assert_eq!(parse_status(""), WorkflowStatus::Completed);
558 assert_eq!(parse_status("QUEUED"), WorkflowStatus::Completed); }
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 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); }
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}