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#[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#[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#[derive(Debug, Deserialize)]
111struct Review {
112 state: String,
113 submitted_at: Option<DateTime<Utc>>,
114 user: Option<ReviewUser>,
115}
116
117#[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 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 #[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 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 pub async fn combined_status_state(&self, commit_sha: &str) -> Result<Option<String>> {
251 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 let check_runs_status = self.get_check_runs_status(commit_sha).await.ok().flatten();
261
262 match (check_runs_status, commit_status) {
264 (Some(cr_status), _) => Ok(Some(cr_status)),
266 (None, Some(status)) => Ok(Some(format!("{:?}", status.state).to_lowercase())),
268 (None, None) => Ok(None),
270 }
271 }
272
273 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); }
285
286 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 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())) }
335 }
336
337 pub async fn get_current_user(&self) -> Result<String> {
339 let user = self.octocrab.current().user().await?;
340 Ok(user.login)
341 }
342
343 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 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 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 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 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 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 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 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 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 pub async fn get_reviews_given(
466 &self,
467 _hours: i64,
468 _username: &str,
469 ) -> Result<Vec<ReviewActivity>> {
470 Ok(vec![])
475 }
476
477 pub async fn get_user_open_prs(&self, username: &str) -> Result<Vec<OpenPrInfo>> {
480 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 let mut results = Vec::new();
495 for issue in response.items {
496 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 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 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 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 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 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 }
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}