Skip to main content

stax/github/
client.rs

1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use octocrab::params::repos::Reference;
4use octocrab::service::middleware::retry::RetryConfig;
5use octocrab::Octocrab;
6use serde::Deserialize;
7use std::collections::{BTreeMap, HashMap};
8use std::sync::atomic::{AtomicUsize, Ordering};
9use std::sync::{Arc, Mutex};
10use std::time::Duration;
11
12use crate::config::{Config, GitHubAuthSource};
13use crate::forge::{PrActivity, RepoIssueListItem, RepoPrListItem, ReviewActivity};
14
15const GITHUB_API_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
16const GITHUB_API_READ_TIMEOUT: Duration = Duration::from_secs(30);
17const GITHUB_API_WRITE_TIMEOUT: Duration = Duration::from_secs(30);
18const GITHUB_API_RETRY_COUNT: usize = 1;
19
20pub struct GitHubClient {
21    pub octocrab: Octocrab,
22    pub owner: String,
23    pub repo: String,
24    auth_source: Option<GitHubAuthSource>,
25    api_call_tracker: Arc<ApiCallTracker>,
26}
27
28impl Clone for GitHubClient {
29    fn clone(&self) -> Self {
30        Self {
31            octocrab: self.octocrab.clone(),
32            owner: self.owner.clone(),
33            repo: self.repo.clone(),
34            auth_source: self.auth_source,
35            api_call_tracker: self.api_call_tracker.clone(),
36        }
37    }
38}
39
40#[derive(Debug, Clone)]
41pub struct ApiCallStats {
42    pub total_requests: usize,
43    pub by_operation: Vec<(String, usize)>,
44}
45
46#[derive(Default)]
47struct ApiCallTracker {
48    total_requests: AtomicUsize,
49    by_operation: Mutex<BTreeMap<String, usize>>,
50}
51
52impl ApiCallTracker {
53    fn record(&self, operation: &'static str, count: usize) {
54        if count == 0 {
55            return;
56        }
57
58        self.total_requests.fetch_add(count, Ordering::Relaxed);
59        let mut by_operation = self.by_operation.lock().unwrap_or_else(|e| e.into_inner());
60        *by_operation.entry(operation.to_string()).or_insert(0) += count;
61    }
62
63    fn snapshot(&self) -> ApiCallStats {
64        let by_operation = self
65            .by_operation
66            .lock()
67            .unwrap_or_else(|e| e.into_inner())
68            .iter()
69            .map(|(operation, count)| (operation.clone(), *count))
70            .collect();
71
72        ApiCallStats {
73            total_requests: self.total_requests.load(Ordering::Relaxed),
74            by_operation,
75        }
76    }
77}
78
79/// Response from the check-runs API
80#[derive(Debug, Deserialize)]
81struct CheckRunsResponse {
82    total_count: usize,
83    check_runs: Vec<CheckRun>,
84}
85
86#[derive(Debug, Deserialize)]
87struct CheckRun {
88    id: u64,
89    name: String,
90    status: String,
91    conclusion: Option<String>,
92}
93
94/// Open PR info for tracking command
95#[derive(Debug, Clone)]
96pub struct OpenPrInfo {
97    pub number: u64,
98    pub head_branch: String,
99    pub base_branch: String,
100    pub state: String,
101    pub is_draft: bool,
102}
103
104#[derive(Debug, Deserialize)]
105struct ReviewUser {
106    login: String,
107}
108
109/// Response from GitHub reviews API
110#[derive(Debug, Deserialize)]
111struct Review {
112    state: String,
113    submitted_at: Option<DateTime<Utc>>,
114    user: Option<ReviewUser>,
115}
116
117/// Response from GitHub search issues API
118#[derive(Debug, Deserialize)]
119struct SearchIssuesResponse {
120    items: Vec<SearchIssue>,
121}
122
123#[derive(Debug, Deserialize)]
124struct SearchIssue {
125    number: u64,
126    title: String,
127    html_url: String,
128    created_at: DateTime<Utc>,
129    closed_at: Option<DateTime<Utc>>,
130}
131
132#[derive(Debug, Deserialize)]
133struct RepoListUser {
134    login: String,
135}
136
137#[derive(Debug, Deserialize)]
138struct RepoListPullRef {
139    #[serde(rename = "ref")]
140    ref_field: String,
141}
142
143#[derive(Debug, Deserialize)]
144struct RepoListPullRequest {
145    number: u64,
146    title: String,
147    html_url: String,
148    user: RepoListUser,
149    head: RepoListPullRef,
150    base: RepoListPullRef,
151    state: String,
152    draft: Option<bool>,
153    created_at: DateTime<Utc>,
154}
155
156#[derive(Debug, Deserialize)]
157struct RepoListLabel {
158    name: Option<String>,
159}
160
161#[derive(Debug, Deserialize)]
162struct RepoListIssue {
163    number: u64,
164    title: String,
165    html_url: String,
166    user: RepoListUser,
167    labels: Vec<RepoListLabel>,
168    updated_at: DateTime<Utc>,
169    pull_request: Option<serde_json::Value>,
170}
171
172impl GitHubClient {
173    /// Create a new GitHub client from config
174    pub fn new(owner: &str, repo: &str, api_base_url: Option<String>) -> Result<Self> {
175        let (auth_source, token) = Config::github_token_with_source().context(
176            "GitHub auth not configured. Use one of: `stax auth`, `stax auth --from-gh`, \
177             `gh auth login`, or set `STAX_GITHUB_TOKEN`.",
178        )?;
179
180        let mut builder = Octocrab::builder()
181            .personal_token(token.to_string())
182            .add_retry_config(RetryConfig::Simple(GITHUB_API_RETRY_COUNT))
183            .set_connect_timeout(Some(GITHUB_API_CONNECT_TIMEOUT))
184            .set_read_timeout(Some(GITHUB_API_READ_TIMEOUT))
185            .set_write_timeout(Some(GITHUB_API_WRITE_TIMEOUT));
186        if let Some(api_base) = api_base_url {
187            builder = builder
188                .base_uri(api_base)
189                .context("Failed to set GitHub API base URL")?;
190        }
191
192        let octocrab = builder.build().context("Failed to create GitHub client")?;
193
194        Ok(Self {
195            octocrab,
196            owner: owner.to_string(),
197            repo: repo.to_string(),
198            auth_source: Some(auth_source),
199            api_call_tracker: Arc::new(ApiCallTracker::default()),
200        })
201    }
202
203    /// Create a new GitHub client with a custom Octocrab instance (for testing)
204    #[cfg(test)]
205    pub fn with_octocrab(octocrab: Octocrab, owner: &str, repo: &str) -> Self {
206        Self {
207            octocrab,
208            owner: owner.to_string(),
209            repo: repo.to_string(),
210            auth_source: None,
211            api_call_tracker: Arc::new(ApiCallTracker::default()),
212        }
213    }
214
215    pub fn api_call_stats(&self) -> ApiCallStats {
216        self.api_call_tracker.snapshot()
217    }
218
219    pub(crate) fn record_api_call(&self, operation: &'static str) {
220        self.api_call_tracker.record(operation, 1);
221    }
222
223    /// Enrich an API error with auth troubleshooting context when it looks
224    /// like a token permissions issue (GitHub returns 404 for private repos
225    /// when the token lacks access, not 403).
226    pub(crate) fn enrich_api_error(&self, err: anyhow::Error) -> anyhow::Error {
227        let msg = format!("{:#}", err);
228        if msg.contains("Not Found")
229            || msg.contains("404")
230            || msg.contains("Unauthorized")
231            || msg.contains("401")
232            || msg.contains("Bad credentials")
233        {
234            let source_hint = match self.auth_source {
235                Some(s) => format!("Current auth source: {}.", s.display_name()),
236                None => "No auth source recorded.".to_string(),
237            };
238            err.context(format!(
239                "GitHub API error for {}/{}. This often means your token is expired or \
240                 lacks access to this repository. {}\n\
241                 To fix: run `stax auth --from-gh` to refresh, or check your token scopes.",
242                self.owner, self.repo, source_hint,
243            ))
244        } else {
245            err
246        }
247    }
248
249    /// Get combined CI status from both commit statuses AND check runs (GitHub Actions)
250    pub async fn combined_status_state(&self, commit_sha: &str) -> Result<Option<String>> {
251        // First, check legacy commit statuses
252        let commit_status = self
253            .octocrab
254            .repos(&self.owner, &self.repo)
255            .combined_status_for_ref(&Reference::Branch(commit_sha.to_string()))
256            .await
257            .ok();
258
259        // Then, check GitHub Actions check runs
260        let check_runs_status = self.get_check_runs_status(commit_sha).await.ok().flatten();
261
262        // Combine results: prioritize check runs (more common), fall back to commit status
263        match (check_runs_status, commit_status) {
264            // If we have check runs, use that status
265            (Some(cr_status), _) => Ok(Some(cr_status)),
266            // Fall back to commit status
267            (None, Some(status)) => Ok(Some(format!("{:?}", status.state).to_lowercase())),
268            // No CI at all
269            (None, None) => Ok(None),
270        }
271    }
272
273    /// Get status from GitHub Actions check runs
274    async fn get_check_runs_status(&self, commit_sha: &str) -> Result<Option<String>> {
275        let url = format!(
276            "/repos/{}/{}/commits/{}/check-runs",
277            self.owner, self.repo, commit_sha
278        );
279
280        let response: CheckRunsResponse = self.octocrab.get(&url, None::<&()>).await?;
281
282        if response.total_count == 0 {
283            return Ok(None); // No check runs configured
284        }
285
286        // Deduplicate check runs by name, keeping the latest (highest id) for each.
287        // GitHub returns all check runs including superseded ones from workflow re-runs.
288        let mut latest_by_name: HashMap<&str, &CheckRun> = HashMap::new();
289        for run in &response.check_runs {
290            let entry = latest_by_name.entry(&run.name).or_insert(run);
291            if run.id > entry.id {
292                *entry = run;
293            }
294        }
295
296        // Analyze deduplicated check runs to determine overall status
297        let mut has_pending = false;
298        let mut has_failure = false;
299        let mut all_success = true;
300
301        for run in latest_by_name.values() {
302            match run.status.as_str() {
303                "completed" => match run.conclusion.as_deref() {
304                    Some("success") | Some("skipped") | Some("neutral") => {}
305                    Some("failure")
306                    | Some("timed_out")
307                    | Some("cancelled")
308                    | Some("action_required") => {
309                        has_failure = true;
310                        all_success = false;
311                    }
312                    _ => {
313                        all_success = false;
314                    }
315                },
316                "queued" | "in_progress" | "waiting" | "requested" | "pending" => {
317                    has_pending = true;
318                    all_success = false;
319                }
320                _ => {
321                    all_success = false;
322                }
323            }
324        }
325
326        if has_failure {
327            Ok(Some("failure".to_string()))
328        } else if has_pending {
329            Ok(Some("pending".to_string()))
330        } else if all_success {
331            Ok(Some("success".to_string()))
332        } else {
333            Ok(Some("pending".to_string())) // Unknown state, treat as pending
334        }
335    }
336
337    /// Get the authenticated user's login name
338    pub async fn get_current_user(&self) -> Result<String> {
339        let user = self.octocrab.current().user().await?;
340        Ok(user.login)
341    }
342
343    /// Get PRs merged by the user in the last N hours
344    pub async fn get_recent_merged_prs(
345        &self,
346        hours: i64,
347        username: &str,
348    ) -> Result<Vec<PrActivity>> {
349        let since = Utc::now() - chrono::Duration::hours(hours);
350        // Use search API to find only user's merged PRs - much faster than listing all
351        let url = format!(
352            "/search/issues?q=repo:{}/{}+author:{}+is:pr+is:merged&sort=updated&order=desc&per_page=30",
353            self.owner, self.repo, username
354        );
355
356        let response: SearchIssuesResponse = self.octocrab.get(&url, None::<&()>).await?;
357
358        let merged: Vec<PrActivity> = response
359            .items
360            .into_iter()
361            .filter_map(|issue| {
362                let closed_at = issue.closed_at?;
363                // Filter by time locally (more reliable than URL date filters)
364                if closed_at < since {
365                    return None;
366                }
367                Some(PrActivity {
368                    number: issue.number,
369                    title: issue.title,
370                    timestamp: closed_at,
371                    url: issue.html_url,
372                })
373            })
374            .collect();
375
376        Ok(merged)
377    }
378
379    /// Get PRs opened by the user in the last N hours
380    pub async fn get_recent_opened_prs(
381        &self,
382        hours: i64,
383        username: &str,
384    ) -> Result<Vec<PrActivity>> {
385        let since = Utc::now() - chrono::Duration::hours(hours);
386        // Use search API to find only user's created PRs
387        let url = format!(
388            "/search/issues?q=repo:{}/{}+author:{}+is:pr&sort=created&order=desc&per_page=30",
389            self.owner, self.repo, username
390        );
391
392        let response: SearchIssuesResponse = self.octocrab.get(&url, None::<&()>).await?;
393
394        let opened: Vec<PrActivity> = response
395            .items
396            .into_iter()
397            .filter(|issue| issue.created_at >= since)
398            .map(|issue| PrActivity {
399                number: issue.number,
400                title: issue.title,
401                timestamp: issue.created_at,
402                url: issue.html_url,
403            })
404            .collect();
405
406        Ok(opened)
407    }
408
409    /// Get reviews received on user's open PRs in the last N hours
410    /// Only fetches user's own PRs to keep it fast
411    pub async fn get_reviews_received(
412        &self,
413        hours: i64,
414        username: &str,
415    ) -> Result<Vec<ReviewActivity>> {
416        let since = Utc::now() - chrono::Duration::hours(hours);
417
418        // Use search to get only user's open PRs (fast)
419        let url = format!(
420            "/search/issues?q=repo:{}/{}+author:{}+is:pr+is:open&per_page=20",
421            self.owner, self.repo, username
422        );
423        let response: SearchIssuesResponse = self.octocrab.get(&url, None::<&()>).await?;
424
425        let mut reviews = Vec::new();
426
427        // Only check reviews on user's own PRs (small list, few API calls)
428        for issue in response.items {
429            let reviews_url = format!(
430                "/repos/{}/{}/pulls/{}/reviews",
431                self.owner, self.repo, issue.number
432            );
433            let pr_reviews: Vec<Review> = self
434                .octocrab
435                .get(&reviews_url, None::<&()>)
436                .await
437                .unwrap_or_default();
438
439            for review in pr_reviews {
440                if let Some(submitted) = review.submitted_at {
441                    if submitted >= since {
442                        if let Some(reviewer) = review.user {
443                            // Don't include self-reviews
444                            if reviewer.login != username {
445                                reviews.push(ReviewActivity {
446                                    pr_number: issue.number,
447                                    pr_title: issue.title.clone(),
448                                    reviewer: reviewer.login,
449                                    state: review.state,
450                                    timestamp: submitted,
451                                    is_received: true,
452                                });
453                            }
454                        }
455                    }
456                }
457            }
458        }
459
460        Ok(reviews)
461    }
462
463    /// Get reviews given by user on others' PRs in the last N hours
464    /// Note: This is expensive for large repos, returns empty to keep standup fast
465    pub async fn get_reviews_given(
466        &self,
467        _hours: i64,
468        _username: &str,
469    ) -> Result<Vec<ReviewActivity>> {
470        // Not yet implemented: scanning all PRs via REST is O(N) and too slow
471        // for large repos. A future version could use GitHub's GraphQL
472        // PullRequestReviewContributionsByRepository connection to fetch this
473        // efficiently in a single query.
474        Ok(vec![])
475    }
476
477    /// Get all open PRs authored by the given user
478    /// Uses Search API for efficient server-side filtering
479    pub async fn get_user_open_prs(&self, username: &str) -> Result<Vec<OpenPrInfo>> {
480        // Use search API to efficiently find user's open PRs
481        let url = format!(
482            "/search/issues?q=repo:{}/{}+author:{}+is:pr+is:open&per_page=100",
483            self.owner, self.repo, username
484        );
485
486        let response: SearchIssuesResponse = self
487            .octocrab
488            .get(&url, None::<&()>)
489            .await
490            .context("Failed to search PRs")?;
491
492        // For each PR from search, we need to get the branch info
493        // Search API doesn't include head/base branch refs, so we fetch each PR
494        let mut results = Vec::new();
495        for issue in response.items {
496            // Fetch full PR details to get branch info
497            let pr = self
498                .octocrab
499                .pulls(&self.owner, &self.repo)
500                .get(issue.number)
501                .await;
502
503            if let Ok(pr) = pr {
504                results.push(OpenPrInfo {
505                    number: pr.number,
506                    head_branch: pr.head.ref_field.clone(),
507                    base_branch: pr.base.ref_field.clone(),
508                    state: "OPEN".to_string(),
509                    is_draft: pr.draft.unwrap_or(false),
510                });
511            }
512        }
513
514        Ok(results)
515    }
516
517    /// List open pull requests for the current repository.
518    pub async fn list_open_pull_requests(&self, limit: u8) -> Result<Vec<RepoPrListItem>> {
519        self.record_api_call("pulls.list");
520        let per_page = limit.clamp(1, 100);
521        let url = format!(
522            "/repos/{}/{}/pulls?state=open&sort=created&direction=desc&per_page={}",
523            self.owner, self.repo, per_page
524        );
525
526        let response: Vec<RepoListPullRequest> = self
527            .octocrab
528            .get(&url, None::<&()>)
529            .await
530            .context("Failed to list pull requests")?;
531
532        Ok(response
533            .into_iter()
534            .take(per_page as usize)
535            .map(|pr| RepoPrListItem {
536                number: pr.number,
537                title: pr.title,
538                url: pr.html_url,
539                author: pr.user.login,
540                head_branch: pr.head.ref_field,
541                base_branch: pr.base.ref_field,
542                state: pr.state,
543                is_draft: pr.draft.unwrap_or(false),
544                created_at: pr.created_at,
545            })
546            .collect())
547    }
548
549    /// List open issues for the current repository.
550    pub async fn list_open_issues(&self, limit: u8) -> Result<Vec<RepoIssueListItem>> {
551        self.record_api_call("issues.list");
552        let per_page = limit.clamp(1, 100);
553        // GitHub returns PRs in this listing; we filter client-side. Request up to 2× so a
554        // PR-heavy first page does not underfill after filtering (capped at API max).
555        let fetch_per_page = (usize::from(per_page) * 2).min(100) as u8;
556        let url = format!(
557            "/repos/{}/{}/issues?state=open&sort=updated&direction=desc&per_page={}",
558            self.owner, self.repo, fetch_per_page
559        );
560
561        let response: Vec<RepoListIssue> = self
562            .octocrab
563            .get(&url, None::<&()>)
564            .await
565            .context("Failed to list issues")?;
566
567        Ok(response
568            .into_iter()
569            .filter(|issue| issue.pull_request.is_none())
570            .take(usize::from(per_page))
571            .map(|issue| RepoIssueListItem {
572                number: issue.number,
573                title: issue.title,
574                url: issue.html_url,
575                author: issue.user.login,
576                labels: issue
577                    .labels
578                    .into_iter()
579                    .filter_map(|label| label.name)
580                    .collect(),
581                updated_at: issue.updated_at,
582            })
583            .collect())
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use wiremock::matchers::{method, path, query_param};
591    use wiremock::{Mock, MockServer, ResponseTemplate};
592
593    fn ensure_crypto_provider() {
594        let _ = rustls::crypto::ring::default_provider().install_default();
595    }
596
597    async fn create_test_client(server: &MockServer) -> GitHubClient {
598        ensure_crypto_provider();
599        let octocrab = Octocrab::builder()
600            .base_uri(server.uri())
601            .unwrap()
602            .personal_token("test-token".to_string())
603            .build()
604            .unwrap();
605
606        GitHubClient::with_octocrab(octocrab, "test-owner", "test-repo")
607    }
608
609    #[tokio::test]
610    async fn test_check_runs_all_success() {
611        let mock_server = MockServer::start().await;
612
613        Mock::given(method("GET"))
614            .and(path(
615                "/repos/test-owner/test-repo/commits/abc123/check-runs",
616            ))
617            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
618                "total_count": 2,
619                "check_runs": [
620                    {"id": 1, "name": "build", "status": "completed", "conclusion": "success"},
621                    {"id": 2, "name": "test", "status": "completed", "conclusion": "success"}
622                ]
623            })))
624            .mount(&mock_server)
625            .await;
626
627        let client = create_test_client(&mock_server).await;
628        let status = client.get_check_runs_status("abc123").await.unwrap();
629        assert_eq!(status, Some("success".to_string()));
630    }
631
632    #[tokio::test]
633    async fn test_check_runs_with_failure() {
634        let mock_server = MockServer::start().await;
635
636        Mock::given(method("GET"))
637            .and(path(
638                "/repos/test-owner/test-repo/commits/abc123/check-runs",
639            ))
640            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
641                "total_count": 3,
642                "check_runs": [
643                    {"id": 1, "name": "build", "status": "completed", "conclusion": "success"},
644                    {"id": 2, "name": "lint", "status": "completed", "conclusion": "failure"},
645                    {"id": 3, "name": "test", "status": "completed", "conclusion": "success"}
646                ]
647            })))
648            .mount(&mock_server)
649            .await;
650
651        let client = create_test_client(&mock_server).await;
652        let status = client.get_check_runs_status("abc123").await.unwrap();
653        assert_eq!(status, Some("failure".to_string()));
654    }
655
656    #[tokio::test]
657    async fn test_check_runs_with_pending() {
658        let mock_server = MockServer::start().await;
659
660        Mock::given(method("GET"))
661            .and(path(
662                "/repos/test-owner/test-repo/commits/abc123/check-runs",
663            ))
664            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
665                "total_count": 2,
666                "check_runs": [
667                    {"id": 1, "name": "build", "status": "completed", "conclusion": "success"},
668                    {"id": 2, "name": "test", "status": "in_progress", "conclusion": null}
669                ]
670            })))
671            .mount(&mock_server)
672            .await;
673
674        let client = create_test_client(&mock_server).await;
675        let status = client.get_check_runs_status("abc123").await.unwrap();
676        assert_eq!(status, Some("pending".to_string()));
677    }
678
679    #[tokio::test]
680    async fn test_check_runs_queued() {
681        let mock_server = MockServer::start().await;
682
683        Mock::given(method("GET"))
684            .and(path(
685                "/repos/test-owner/test-repo/commits/abc123/check-runs",
686            ))
687            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
688                "total_count": 1,
689                "check_runs": [
690                    {"id": 1, "name": "build", "status": "queued", "conclusion": null}
691                ]
692            })))
693            .mount(&mock_server)
694            .await;
695
696        let client = create_test_client(&mock_server).await;
697        let status = client.get_check_runs_status("abc123").await.unwrap();
698        assert_eq!(status, Some("pending".to_string()));
699    }
700
701    #[tokio::test]
702    async fn test_check_runs_waiting() {
703        let mock_server = MockServer::start().await;
704
705        Mock::given(method("GET"))
706            .and(path(
707                "/repos/test-owner/test-repo/commits/abc123/check-runs",
708            ))
709            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
710                "total_count": 1,
711                "check_runs": [
712                    {"id": 1, "name": "build", "status": "waiting", "conclusion": null}
713                ]
714            })))
715            .mount(&mock_server)
716            .await;
717
718        let client = create_test_client(&mock_server).await;
719        let status = client.get_check_runs_status("abc123").await.unwrap();
720        assert_eq!(status, Some("pending".to_string()));
721    }
722
723    #[tokio::test]
724    async fn test_check_runs_no_checks() {
725        let mock_server = MockServer::start().await;
726
727        Mock::given(method("GET"))
728            .and(path(
729                "/repos/test-owner/test-repo/commits/abc123/check-runs",
730            ))
731            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
732                "total_count": 0,
733                "check_runs": []
734            })))
735            .mount(&mock_server)
736            .await;
737
738        let client = create_test_client(&mock_server).await;
739        let status = client.get_check_runs_status("abc123").await.unwrap();
740        assert_eq!(status, None);
741    }
742
743    #[tokio::test]
744    async fn test_check_runs_skipped_and_neutral() {
745        let mock_server = MockServer::start().await;
746
747        Mock::given(method("GET"))
748            .and(path(
749                "/repos/test-owner/test-repo/commits/abc123/check-runs",
750            ))
751            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
752                "total_count": 3,
753                "check_runs": [
754                    {"id": 1, "name": "build", "status": "completed", "conclusion": "success"},
755                    {"id": 2, "name": "release", "status": "completed", "conclusion": "skipped"},
756                    {"id": 3, "name": "deploy", "status": "completed", "conclusion": "neutral"}
757                ]
758            })))
759            .mount(&mock_server)
760            .await;
761
762        let client = create_test_client(&mock_server).await;
763        let status = client.get_check_runs_status("abc123").await.unwrap();
764        assert_eq!(status, Some("success".to_string()));
765    }
766
767    #[tokio::test]
768    async fn test_check_runs_timed_out() {
769        let mock_server = MockServer::start().await;
770
771        Mock::given(method("GET"))
772            .and(path(
773                "/repos/test-owner/test-repo/commits/abc123/check-runs",
774            ))
775            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
776                "total_count": 1,
777                "check_runs": [
778                    {"id": 1, "name": "build", "status": "completed", "conclusion": "timed_out"}
779                ]
780            })))
781            .mount(&mock_server)
782            .await;
783
784        let client = create_test_client(&mock_server).await;
785        let status = client.get_check_runs_status("abc123").await.unwrap();
786        assert_eq!(status, Some("failure".to_string()));
787    }
788
789    #[tokio::test]
790    async fn test_check_runs_cancelled() {
791        let mock_server = MockServer::start().await;
792
793        Mock::given(method("GET"))
794            .and(path(
795                "/repos/test-owner/test-repo/commits/abc123/check-runs",
796            ))
797            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
798                "total_count": 1,
799                "check_runs": [
800                    {"id": 1, "name": "build", "status": "completed", "conclusion": "cancelled"}
801                ]
802            })))
803            .mount(&mock_server)
804            .await;
805
806        let client = create_test_client(&mock_server).await;
807        let status = client.get_check_runs_status("abc123").await.unwrap();
808        assert_eq!(status, Some("failure".to_string()));
809    }
810
811    #[tokio::test]
812    async fn test_check_runs_action_required() {
813        let mock_server = MockServer::start().await;
814
815        Mock::given(method("GET"))
816            .and(path(
817                "/repos/test-owner/test-repo/commits/abc123/check-runs",
818            ))
819            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
820                "total_count": 1,
821                "check_runs": [
822                    {"id": 1, "name": "build", "status": "completed", "conclusion": "action_required"}
823                ]
824            })))
825            .mount(&mock_server)
826            .await;
827
828        let client = create_test_client(&mock_server).await;
829        let status = client.get_check_runs_status("abc123").await.unwrap();
830        assert_eq!(status, Some("failure".to_string()));
831    }
832
833    #[tokio::test]
834    async fn test_check_runs_unknown_conclusion() {
835        let mock_server = MockServer::start().await;
836
837        Mock::given(method("GET"))
838            .and(path(
839                "/repos/test-owner/test-repo/commits/abc123/check-runs",
840            ))
841            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
842                "total_count": 1,
843                "check_runs": [
844                    {"id": 1, "name": "build", "status": "completed", "conclusion": "unknown_state"}
845                ]
846            })))
847            .mount(&mock_server)
848            .await;
849
850        let client = create_test_client(&mock_server).await;
851        let status = client.get_check_runs_status("abc123").await.unwrap();
852        // Unknown conclusion treated as not all_success, but not failure or pending
853        assert_eq!(status, Some("pending".to_string()));
854    }
855
856    #[tokio::test]
857    async fn test_check_runs_unknown_status() {
858        let mock_server = MockServer::start().await;
859
860        Mock::given(method("GET"))
861            .and(path(
862                "/repos/test-owner/test-repo/commits/abc123/check-runs",
863            ))
864            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
865                "total_count": 1,
866                "check_runs": [
867                    {"id": 1, "name": "build", "status": "some_unknown_status", "conclusion": null}
868                ]
869            })))
870            .mount(&mock_server)
871            .await;
872
873        let client = create_test_client(&mock_server).await;
874        let status = client.get_check_runs_status("abc123").await.unwrap();
875        // Unknown status treated as pending
876        assert_eq!(status, Some("pending".to_string()));
877    }
878
879    #[tokio::test]
880    async fn test_check_runs_requested_status() {
881        let mock_server = MockServer::start().await;
882
883        Mock::given(method("GET"))
884            .and(path(
885                "/repos/test-owner/test-repo/commits/abc123/check-runs",
886            ))
887            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
888                "total_count": 1,
889                "check_runs": [
890                    {"id": 1, "name": "build", "status": "requested", "conclusion": null}
891                ]
892            })))
893            .mount(&mock_server)
894            .await;
895
896        let client = create_test_client(&mock_server).await;
897        let status = client.get_check_runs_status("abc123").await.unwrap();
898        assert_eq!(status, Some("pending".to_string()));
899    }
900
901    #[tokio::test]
902    async fn test_check_runs_pending_status() {
903        let mock_server = MockServer::start().await;
904
905        Mock::given(method("GET"))
906            .and(path(
907                "/repos/test-owner/test-repo/commits/abc123/check-runs",
908            ))
909            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
910                "total_count": 1,
911                "check_runs": [
912                    {"id": 1, "name": "build", "status": "pending", "conclusion": null}
913                ]
914            })))
915            .mount(&mock_server)
916            .await;
917
918        let client = create_test_client(&mock_server).await;
919        let status = client.get_check_runs_status("abc123").await.unwrap();
920        assert_eq!(status, Some("pending".to_string()));
921    }
922
923    #[tokio::test]
924    async fn test_check_runs_rerun_supersedes_failure() {
925        let mock_server = MockServer::start().await;
926
927        Mock::given(method("GET"))
928            .and(path(
929                "/repos/test-owner/test-repo/commits/abc123/check-runs",
930            ))
931            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
932                "total_count": 4,
933                "check_runs": [
934                    {"id": 100, "name": "lint", "status": "completed", "conclusion": "success"},
935                    {"id": 101, "name": "build", "status": "completed", "conclusion": "failure"},
936                    {"id": 102, "name": "test", "status": "completed", "conclusion": "success"},
937                    {"id": 200, "name": "build", "status": "completed", "conclusion": "success"}
938                ]
939            })))
940            .mount(&mock_server)
941            .await;
942
943        let client = create_test_client(&mock_server).await;
944        let status = client.get_check_runs_status("abc123").await.unwrap();
945        assert_eq!(status, Some("success".to_string()));
946    }
947
948    #[tokio::test]
949    async fn test_with_octocrab() {
950        ensure_crypto_provider();
951        let mock_server = MockServer::start().await;
952
953        let octocrab = Octocrab::builder()
954            .base_uri(mock_server.uri())
955            .unwrap()
956            .personal_token("test-token".to_string())
957            .build()
958            .unwrap();
959
960        let client = GitHubClient::with_octocrab(octocrab, "owner", "repo");
961        assert_eq!(client.owner, "owner");
962        assert_eq!(client.repo, "repo");
963    }
964
965    #[test]
966    fn test_check_run_response_deserialization() {
967        let json = r#"{
968            "total_count": 2,
969            "check_runs": [
970                {"id": 1, "name": "build", "status": "completed", "conclusion": "success"},
971                {"id": 2, "name": "test", "status": "in_progress", "conclusion": null}
972            ]
973        }"#;
974
975        let response: CheckRunsResponse = serde_json::from_str(json).unwrap();
976        assert_eq!(response.total_count, 2);
977        assert_eq!(response.check_runs.len(), 2);
978        assert_eq!(response.check_runs[0].status, "completed");
979        assert_eq!(
980            response.check_runs[0].conclusion,
981            Some("success".to_string())
982        );
983        assert_eq!(response.check_runs[1].status, "in_progress");
984        assert_eq!(response.check_runs[1].conclusion, None);
985    }
986
987    #[test]
988    fn test_check_run_deserialization() {
989        let json = r#"{"id": 1, "name": "build", "status": "completed", "conclusion": "failure"}"#;
990        let check_run: CheckRun = serde_json::from_str(json).unwrap();
991        assert_eq!(check_run.status, "completed");
992        assert_eq!(check_run.conclusion, Some("failure".to_string()));
993    }
994
995    #[tokio::test]
996    async fn test_list_open_pull_requests() {
997        let mock_server = MockServer::start().await;
998
999        Mock::given(method("GET"))
1000            .and(path("/repos/test-owner/test-repo/pulls"))
1001            .and(query_param("state", "open"))
1002            .and(query_param("sort", "created"))
1003            .and(query_param("direction", "desc"))
1004            .and(query_param("per_page", "30"))
1005            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
1006                {
1007                    "number": 114,
1008                    "title": "worktrees enhanced",
1009                    "html_url": "https://github.com/test-owner/test-repo/pull/114",
1010                    "user": { "login": "cesar" },
1011                    "head": { "ref": "cesar/worktrees-enhanced" },
1012                    "base": { "ref": "main" },
1013                    "state": "open",
1014                    "draft": false,
1015                    "created_at": "2026-03-15T10:00:00Z"
1016                }
1017            ])))
1018            .mount(&mock_server)
1019            .await;
1020
1021        let client = create_test_client(&mock_server).await;
1022        let prs = client.list_open_pull_requests(30).await.unwrap();
1023
1024        assert_eq!(prs.len(), 1);
1025        assert_eq!(prs[0].number, 114);
1026        assert_eq!(prs[0].title, "worktrees enhanced");
1027        assert_eq!(prs[0].author, "cesar");
1028        assert_eq!(prs[0].head_branch, "cesar/worktrees-enhanced");
1029        assert_eq!(prs[0].base_branch, "main");
1030        assert_eq!(prs[0].state, "open");
1031        assert!(!prs[0].is_draft);
1032    }
1033
1034    #[tokio::test]
1035    async fn test_list_open_pull_requests_preserves_draft_state() {
1036        let mock_server = MockServer::start().await;
1037
1038        Mock::given(method("GET"))
1039            .and(path("/repos/test-owner/test-repo/pulls"))
1040            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
1041                {
1042                    "number": 45,
1043                    "title": "draft stack cleanup",
1044                    "html_url": "https://github.com/test-owner/test-repo/pull/45",
1045                    "user": { "login": "cesar" },
1046                    "head": { "ref": "codex/draft-stack-cleanup" },
1047                    "base": { "ref": "main" },
1048                    "state": "open",
1049                    "draft": true,
1050                    "created_at": "2026-03-14T09:00:00Z"
1051                }
1052            ])))
1053            .mount(&mock_server)
1054            .await;
1055
1056        let client = create_test_client(&mock_server).await;
1057        let prs = client.list_open_pull_requests(30).await.unwrap();
1058
1059        assert_eq!(prs.len(), 1);
1060        assert!(prs[0].is_draft);
1061    }
1062
1063    #[tokio::test]
1064    async fn test_list_open_issues_filters_pull_requests_and_reads_labels() {
1065        let mock_server = MockServer::start().await;
1066
1067        Mock::given(method("GET"))
1068            .and(path("/repos/test-owner/test-repo/issues"))
1069            .and(query_param("state", "open"))
1070            .and(query_param("sort", "updated"))
1071            .and(query_param("direction", "desc"))
1072            .and(query_param("per_page", "60"))
1073            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
1074                {
1075                    "number": 113,
1076                    "title": "Handle browser launcher failures",
1077                    "html_url": "https://github.com/test-owner/test-repo/issues/113",
1078                    "user": { "login": "cesar" },
1079                    "labels": [],
1080                    "updated_at": "2026-03-15T11:00:00Z"
1081                },
1082                {
1083                    "number": 112,
1084                    "title": "This is actually a pull request",
1085                    "html_url": "https://github.com/test-owner/test-repo/issues/112",
1086                    "user": { "login": "cesar" },
1087                    "labels": [],
1088                    "updated_at": "2026-03-15T10:00:00Z",
1089                    "pull_request": {
1090                        "url": "https://api.github.com/repos/test-owner/test-repo/pulls/112"
1091                    }
1092                },
1093                {
1094                    "number": 77,
1095                    "title": "Gitlab Support",
1096                    "html_url": "https://github.com/test-owner/test-repo/issues/77",
1097                    "user": { "login": "geoHeil" },
1098                    "labels": [
1099                        { "name": "help wanted" },
1100                        { "name": "integration" }
1101                    ],
1102                    "updated_at": "2026-03-14T12:30:00Z"
1103                }
1104            ])))
1105            .mount(&mock_server)
1106            .await;
1107
1108        let client = create_test_client(&mock_server).await;
1109        let issues = client.list_open_issues(30).await.unwrap();
1110
1111        assert_eq!(issues.len(), 2);
1112        assert_eq!(issues[0].number, 113);
1113        assert!(issues[0].labels.is_empty());
1114        assert_eq!(issues[1].number, 77);
1115        assert_eq!(issues[1].labels, vec!["help wanted", "integration"]);
1116    }
1117
1118    #[tokio::test]
1119    async fn test_list_open_issues_overfetches_to_fill_after_pr_pollution() {
1120        let mock_server = MockServer::start().await;
1121
1122        Mock::given(method("GET"))
1123            .and(path("/repos/test-owner/test-repo/issues"))
1124            .and(query_param("state", "open"))
1125            .and(query_param("sort", "updated"))
1126            .and(query_param("direction", "desc"))
1127            .and(query_param("per_page", "4"))
1128            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
1129                {
1130                    "number": 201,
1131                    "title": "PR one",
1132                    "html_url": "https://github.com/test-owner/test-repo/pull/201",
1133                    "user": { "login": "u" },
1134                    "labels": [],
1135                    "updated_at": "2026-03-15T12:00:00Z",
1136                    "pull_request": {
1137                        "url": "https://api.github.com/repos/test-owner/test-repo/pulls/201"
1138                    }
1139                },
1140                {
1141                    "number": 202,
1142                    "title": "PR two",
1143                    "html_url": "https://github.com/test-owner/test-repo/pull/202",
1144                    "user": { "login": "u" },
1145                    "labels": [],
1146                    "updated_at": "2026-03-15T11:00:00Z",
1147                    "pull_request": {
1148                        "url": "https://api.github.com/repos/test-owner/test-repo/pulls/202"
1149                    }
1150                },
1151                {
1152                    "number": 10,
1153                    "title": "Real issue A",
1154                    "html_url": "https://github.com/test-owner/test-repo/issues/10",
1155                    "user": { "login": "u" },
1156                    "labels": [],
1157                    "updated_at": "2026-03-14T10:00:00Z"
1158                },
1159                {
1160                    "number": 11,
1161                    "title": "Real issue B",
1162                    "html_url": "https://github.com/test-owner/test-repo/issues/11",
1163                    "user": { "login": "u" },
1164                    "labels": [],
1165                    "updated_at": "2026-03-14T09:00:00Z"
1166                }
1167            ])))
1168            .mount(&mock_server)
1169            .await;
1170
1171        let client = create_test_client(&mock_server).await;
1172        let issues = client.list_open_issues(2).await.unwrap();
1173
1174        assert_eq!(issues.len(), 2);
1175        assert_eq!(issues[0].number, 10);
1176        assert_eq!(issues[1].number, 11);
1177    }
1178
1179    #[tokio::test]
1180    async fn test_list_open_pull_requests_empty_response() {
1181        let mock_server = MockServer::start().await;
1182
1183        Mock::given(method("GET"))
1184            .and(path("/repos/test-owner/test-repo/pulls"))
1185            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1186            .mount(&mock_server)
1187            .await;
1188
1189        let client = create_test_client(&mock_server).await;
1190        let prs = client.list_open_pull_requests(30).await.unwrap();
1191        assert!(prs.is_empty());
1192    }
1193
1194    #[tokio::test]
1195    async fn test_list_open_issues_empty_response() {
1196        let mock_server = MockServer::start().await;
1197
1198        Mock::given(method("GET"))
1199            .and(path("/repos/test-owner/test-repo/issues"))
1200            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
1201            .mount(&mock_server)
1202            .await;
1203
1204        let client = create_test_client(&mock_server).await;
1205        let issues = client.list_open_issues(30).await.unwrap();
1206        assert!(issues.is_empty());
1207    }
1208
1209    #[test]
1210    fn test_github_client_clone() {
1211        // This test just verifies Clone is implemented
1212        // We can't actually test it without a mock server setup
1213    }
1214
1215    #[tokio::test]
1216    async fn test_enrich_api_error_adds_auth_context_on_not_found() {
1217        ensure_crypto_provider();
1218        let octocrab = Octocrab::builder()
1219            .personal_token("expired-token".to_string())
1220            .build()
1221            .unwrap();
1222
1223        let mut client = GitHubClient::with_octocrab(octocrab, "myorg", "myrepo");
1224        client.auth_source = Some(GitHubAuthSource::CredentialsFile);
1225
1226        let original = anyhow::anyhow!("Not Found");
1227        let enriched = client.enrich_api_error(original);
1228        let msg = format!("{:#}", enriched);
1229
1230        assert!(
1231            msg.contains("token is expired or lacks access"),
1232            "Expected auth hint, got: {}",
1233            msg
1234        );
1235        assert!(
1236            msg.contains("credentials file"),
1237            "Expected auth source in message, got: {}",
1238            msg
1239        );
1240        assert!(
1241            msg.contains("stax auth --from-gh"),
1242            "Expected fix suggestion, got: {}",
1243            msg
1244        );
1245    }
1246
1247    #[tokio::test]
1248    async fn test_enrich_api_error_passes_through_non_auth_errors() {
1249        ensure_crypto_provider();
1250        let octocrab = Octocrab::builder()
1251            .personal_token("token".to_string())
1252            .build()
1253            .unwrap();
1254
1255        let client = GitHubClient::with_octocrab(octocrab, "myorg", "myrepo");
1256
1257        let original = anyhow::anyhow!("Connection timeout");
1258        let enriched = client.enrich_api_error(original);
1259        let msg = format!("{:#}", enriched);
1260
1261        assert!(
1262            !msg.contains("token is expired"),
1263            "Non-auth errors should not get auth hint, got: {}",
1264            msg
1265        );
1266    }
1267
1268    #[tokio::test]
1269    async fn test_find_open_pr_by_head_404_gives_auth_hint() {
1270        let mock_server = MockServer::start().await;
1271
1272        Mock::given(method("GET"))
1273            .and(path("/repos/test-owner/test-repo/pulls"))
1274            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
1275                "message": "Not Found",
1276                "documentation_url": "https://docs.github.com/rest"
1277            })))
1278            .mount(&mock_server)
1279            .await;
1280
1281        let client = create_test_client(&mock_server).await;
1282        let result = client.find_open_pr_by_head("test-owner", "my-branch").await;
1283
1284        assert!(result.is_err(), "Expected error on 404");
1285        let err_msg = format!("{:#}", result.unwrap_err());
1286        assert!(
1287            err_msg.contains("token is expired or lacks access"),
1288            "Expected auth hint in 404 error, got: {}",
1289            err_msg
1290        );
1291    }
1292}