1use std::net::IpAddr;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use reqwest::{Client, header};
6use serde::de::DeserializeOwned;
7use url::{Host, Url};
8
9use travelagent_core::error::{Result, TrvError};
10use travelagent_core::forge::{
11 ForgeComments, ForgeMerge, ForgeReactions, ForgeRead, ForgeReview, ForgeType, ForgeWarnHandler,
12 MergeMethod, MergeableStatus, NewComment, NewReview, Permissions, PrId, PrListFilter,
13 PrListItem, PrMetadata, PrState, ReactionContent, ReactionTarget, RemoteComment, ReviewThread,
14 ReviewVerdict, User,
15};
16use travelagent_core::model::{DiffFile, FileStatus, LineSide};
17use travelagent_core::vcs::CommitInfo;
18use travelagent_core::vcs::diff_parser::parse_patch_hunks;
19
20use crate::auth;
21use crate::types::{
22 GhCommit, GhCommitDetail2, GhFile, GhIssueComment, GhPullRequest, GhPullRequestListItem,
23 GhRepo, GhRepoPermissions, GhReviewComment, GhUser,
24};
25
26const MAX_ERROR_BODY_BYTES: usize = 2 * 1024;
30
31const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
33const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
35
36pub struct GitHubForge {
37 client: Client,
38 base_url: String,
39 warn_handler: Option<ForgeWarnHandler>,
44}
45
46impl GitHubForge {
47 pub fn new() -> Result<Self> {
48 Self::with_base_url("https://api.github.com")
49 }
50
51 fn validate_base_url(base_url: &str, allow_insecure: bool) -> Result<String> {
57 let parsed = Url::parse(base_url)
58 .map_err(|_| TrvError::AuthError("base_url is not a valid URL".to_string()))?;
59
60 if !allow_insecure && parsed.scheme() != "https" {
61 return Err(TrvError::AuthError(
62 "base_url must use https:// scheme".to_string(),
63 ));
64 }
65 if parsed.scheme() != "https" && parsed.scheme() != "http" {
66 return Err(TrvError::AuthError(
67 "base_url must use http:// or https:// scheme".to_string(),
68 ));
69 }
70
71 if !allow_insecure {
72 match parsed.host() {
73 Some(Host::Ipv4(ip)) => {
74 let ip = IpAddr::V4(ip);
75 if is_blocked_ip(&ip) {
76 return Err(TrvError::AuthError(
77 "base_url host resolves to a private, loopback, or link-local address"
78 .to_string(),
79 ));
80 }
81 }
82 Some(Host::Ipv6(ip)) => {
83 let ip = IpAddr::V6(ip);
84 if is_blocked_ip(&ip) {
85 return Err(TrvError::AuthError(
86 "base_url host resolves to a private, loopback, or link-local address"
87 .to_string(),
88 ));
89 }
90 }
91 Some(Host::Domain(_)) => {}
92 None => {
93 return Err(TrvError::AuthError(
94 "base_url must include a host".to_string(),
95 ));
96 }
97 }
98 }
99
100 Ok(base_url.trim_end_matches('/').to_string())
103 }
104
105 pub fn with_base_url(base_url: &str) -> Result<Self> {
106 let token = auth::resolve_token()?;
107 Self::with_token(base_url, token)
108 }
109
110 pub fn with_token(base_url: &str, token: String) -> Result<Self> {
111 Self::with_token_validated(base_url, token, false)
113 }
114
115 pub fn with_token_insecure(base_url: &str, token: String) -> Result<Self> {
119 Self::with_token_validated(base_url, token, true)
120 }
121
122 fn with_token_validated(base_url: &str, token: String, allow_insecure: bool) -> Result<Self> {
123 let normalized_base = Self::validate_base_url(base_url, allow_insecure)?;
124
125 let mut headers = header::HeaderMap::new();
126 headers.insert(
127 header::ACCEPT,
128 header::HeaderValue::from_static("application/vnd.github+json"),
129 );
130 headers.insert(
131 "X-GitHub-Api-Version",
132 header::HeaderValue::from_static("2022-11-28"),
133 );
134 headers.insert(
135 header::AUTHORIZATION,
136 format!("Bearer {token}").parse().map_err(|_| {
137 TrvError::AuthError(
138 "token contains invalid header bytes; check GITHUB_TOKEN".to_string(),
139 )
140 })?,
141 );
142 headers.insert(
143 header::USER_AGENT,
144 header::HeaderValue::from_static("travelagent"),
145 );
146
147 let client = Client::builder()
149 .default_headers(headers)
150 .timeout(REQUEST_TIMEOUT)
151 .connect_timeout(CONNECT_TIMEOUT)
152 .build()
153 .map_err(|e| TrvError::ForgeApi(format!("Failed to create HTTP client: {e}")))?;
154
155 Ok(Self {
156 client,
157 base_url: normalized_base,
158 warn_handler: None,
159 })
160 }
161
162 pub fn with_warn_handler(mut self, handler: ForgeWarnHandler) -> Self {
165 self.warn_handler = Some(handler);
166 self
167 }
168
169 fn emit_warning(&self, msg: String) {
172 if let Some(ref handler) = self.warn_handler {
173 handler(msg);
174 } else {
175 eprintln!("{msg}");
176 }
177 }
178
179 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
180 let url = format!("{}{}", self.base_url, path);
181 let resp = self
182 .client
183 .get(&url)
184 .send()
185 .await
186 .map_err(|e| TrvError::ForgeApi(format!("Request failed: {e}")))?;
187
188 let resp = Self::check_response(resp, &url).await?;
189
190 resp.json()
191 .await
192 .map_err(|e| TrvError::ForgeApi(format!("Failed to parse response: {e}")))
193 }
194
195 async fn post<T: DeserializeOwned>(&self, path: &str, body: &serde_json::Value) -> Result<T> {
196 let url = format!("{}{}", self.base_url, path);
197 let resp = self
198 .client
199 .post(&url)
200 .json(body)
201 .send()
202 .await
203 .map_err(|e| TrvError::ForgeApi(format!("Request failed: {e}")))?;
204
205 let resp = Self::check_response(resp, &url).await?;
206
207 resp.json()
208 .await
209 .map_err(|e| TrvError::ForgeApi(format!("Failed to parse response: {e}")))
210 }
211
212 async fn post_no_content(&self, path: &str, body: &serde_json::Value) -> Result<()> {
213 let url = format!("{}{}", self.base_url, path);
214 let resp = self
215 .client
216 .post(&url)
217 .json(body)
218 .send()
219 .await
220 .map_err(|e| TrvError::ForgeApi(format!("Request failed: {e}")))?;
221
222 Self::check_response(resp, &url).await?;
223 Ok(())
224 }
225
226 async fn patch<T: DeserializeOwned>(&self, path: &str, body: &serde_json::Value) -> Result<T> {
227 let url = format!("{}{}", self.base_url, path);
228 let resp = self
229 .client
230 .patch(&url)
231 .json(body)
232 .send()
233 .await
234 .map_err(|e| TrvError::ForgeApi(format!("Request failed: {e}")))?;
235
236 let resp = Self::check_response(resp, &url).await?;
237
238 resp.json()
239 .await
240 .map_err(|e| TrvError::ForgeApi(format!("Failed to parse response: {e}")))
241 }
242
243 async fn put_no_content(&self, path: &str, body: &serde_json::Value) -> Result<()> {
244 let url = format!("{}{}", self.base_url, path);
245 let resp = self
246 .client
247 .put(&url)
248 .json(body)
249 .send()
250 .await
251 .map_err(|e| TrvError::ForgeApi(format!("Request failed: {e}")))?;
252
253 Self::check_response(resp, &url).await?;
254 Ok(())
255 }
256
257 async fn delete(&self, path: &str) -> Result<()> {
258 let url = format!("{}{}", self.base_url, path);
259 let resp = self
260 .client
261 .delete(&url)
262 .send()
263 .await
264 .map_err(|e| TrvError::ForgeApi(format!("Request failed: {e}")))?;
265
266 Self::check_response(resp, &url).await?;
267 Ok(())
268 }
269
270 fn check_response_status(resp: &reqwest::Response, url: &str) -> Result<()> {
273 match resp.status().as_u16() {
274 200..=299 => Ok(()),
275 401 => Err(TrvError::AuthError(
276 "GitHub API authentication failed".into(),
277 )),
278 403 if resp
279 .headers()
280 .get("x-ratelimit-remaining")
281 .and_then(|v| v.to_str().ok())
282 == Some("0") =>
283 {
284 Err(TrvError::RateLimited)
285 }
286 404 => Err(TrvError::NotFound(url.to_string())),
287 status => Err(TrvError::ForgeApi(format!("GitHub API {status} for {url}"))),
288 }
289 }
290
291 async fn check_response(resp: reqwest::Response, url: &str) -> Result<reqwest::Response> {
292 match resp.status().as_u16() {
293 200..=299 => Ok(resp),
294 401 => Err(TrvError::AuthError(
295 "GitHub API authentication failed".into(),
296 )),
297 403 if resp
298 .headers()
299 .get("x-ratelimit-remaining")
300 .and_then(|v| v.to_str().ok())
301 == Some("0") =>
302 {
303 Err(TrvError::RateLimited)
304 }
305 404 => {
306 let body = read_error_body(resp).await;
307 let body = sanitize_error_body(&body);
308 Err(TrvError::NotFound(format!("{url}: {body}")))
309 }
310 status => {
311 let body = read_error_body(resp).await;
312 let body = sanitize_error_body(&body);
313 Err(TrvError::ForgeApi(format!(
314 "GitHub API {status} for {url}: {body}"
315 )))
316 }
317 }
318 }
319
320 async fn get_all_pages<T: DeserializeOwned>(&self, path: &str) -> Result<Vec<T>> {
321 const MAX_PAGES: usize = 50;
322 let mut all: Vec<T> = Vec::new();
323 let mut page_count: usize = 0;
324 let mut url = if path.contains('?') {
325 format!("{}{}&per_page=100", self.base_url, path)
326 } else {
327 format!("{}{}?per_page=100", self.base_url, path)
328 };
329 loop {
330 if page_count >= MAX_PAGES {
331 self.emit_warning(format!(
336 "trv: warning: GitHub pagination hit MAX_PAGES={MAX_PAGES} limit for {path}; results may be truncated"
337 ));
338 break;
339 }
340 page_count += 1;
341
342 let resp = self
343 .client
344 .get(&url)
345 .send()
346 .await
347 .map_err(|e| TrvError::ForgeApi(format!("Request failed: {e}")))?;
348
349 Self::check_response_status(&resp, &url)?;
350
351 let next_url = resp
352 .headers()
353 .get("link")
354 .and_then(|v| v.to_str().ok())
355 .and_then(parse_next_link);
356
357 let page: Vec<T> = resp
358 .json()
359 .await
360 .map_err(|e| TrvError::ForgeApi(format!("Failed to parse response: {e}")))?;
361 all.extend(page);
362
363 match next_url {
364 Some(next) => url = next,
365 None => break,
366 }
367 }
368 Ok(all)
369 }
370
371 async fn get_pages_up_to<T: DeserializeOwned>(
375 &self,
376 path: &str,
377 limit: usize,
378 ) -> Result<Vec<T>> {
379 const MAX_PAGES: usize = 50;
380 let per_page = limit.clamp(1, 100);
381 let mut all: Vec<T> = Vec::new();
382 let mut page_count: usize = 0;
383 let mut url = if path.contains('?') {
384 format!("{}{}&per_page={}", self.base_url, path, per_page)
385 } else {
386 format!("{}{}?per_page={}", self.base_url, path, per_page)
387 };
388 loop {
389 if page_count >= MAX_PAGES {
390 self.emit_warning(format!(
391 "trv: warning: GitHub pagination hit MAX_PAGES={MAX_PAGES} limit for {path}; results may be truncated"
392 ));
393 break;
394 }
395 page_count += 1;
396
397 let resp = self
398 .client
399 .get(&url)
400 .send()
401 .await
402 .map_err(|e| TrvError::ForgeApi(format!("Request failed: {e}")))?;
403
404 Self::check_response_status(&resp, &url)?;
405
406 let next_url = resp
407 .headers()
408 .get("link")
409 .and_then(|v| v.to_str().ok())
410 .and_then(parse_next_link);
411
412 let page: Vec<T> = resp
413 .json()
414 .await
415 .map_err(|e| TrvError::ForgeApi(format!("Failed to parse response: {e}")))?;
416 all.extend(page);
417
418 if all.len() >= limit {
419 all.truncate(limit);
420 break;
421 }
422
423 match next_url {
424 Some(next) => url = next,
425 None => break,
426 }
427 }
428 Ok(all)
429 }
430
431 fn graphql_url(&self) -> String {
435 if self.base_url.ends_with("/api/v3") {
436 format!("{}/graphql", self.base_url.trim_end_matches("/v3"))
438 } else {
439 format!("{}/graphql", self.base_url)
441 }
442 }
443
444 async fn graphql_with_vars(
445 &self,
446 query: &str,
447 variables: serde_json::Value,
448 ) -> Result<serde_json::Value> {
449 let url = self.graphql_url();
450 let resp = self
451 .client
452 .post(&url)
453 .json(&serde_json::json!({ "query": query, "variables": variables }))
454 .send()
455 .await
456 .map_err(|e| TrvError::ForgeApi(format!("GraphQL request failed: {e}")))?;
457
458 let resp = Self::check_response(resp, &url).await?;
459
460 let body: serde_json::Value = resp
461 .json()
462 .await
463 .map_err(|e| TrvError::ForgeApi(format!("GraphQL parse error: {e}")))?;
464
465 if let Some(errors) = body.get("errors") {
466 return Err(TrvError::ForgeApi(format!("GraphQL errors: {errors}")));
467 }
468
469 Ok(body)
470 }
471}
472
473fn is_blocked_ip(ip: &IpAddr) -> bool {
477 if ip.is_loopback() || ip.is_unspecified() {
478 return true;
479 }
480 match ip {
481 IpAddr::V4(v4) => {
482 v4.is_private()
483 || v4.is_link_local()
484 || v4.is_broadcast()
485 || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64)
487 }
488 IpAddr::V6(v6) => {
489 (v6.segments()[0] & 0xfe00) == 0xfc00
491 || (v6.segments()[0] & 0xffc0) == 0xfe80
493 || v6
495 .to_ipv4_mapped()
496 .is_some_and(|v4| is_blocked_ip(&IpAddr::V4(v4)))
497 }
498 }
499}
500
501async fn read_error_body(resp: reqwest::Response) -> String {
508 match resp.text().await {
509 Ok(body) => body,
510 Err(e) => format!("<body read failed: {e}>"),
511 }
512}
513
514fn sanitize_error_body(body: &str) -> String {
519 let filtered: String = body
523 .lines()
524 .filter(|line| !line_looks_like_credential_header(line))
525 .collect::<Vec<_>>()
526 .join("\n");
527
528 if filtered.len() <= MAX_ERROR_BODY_BYTES {
529 filtered
530 } else {
531 let mut end = MAX_ERROR_BODY_BYTES;
534 while end > 0 && !filtered.is_char_boundary(end) {
535 end -= 1;
536 }
537 let mut out = filtered[..end].to_string();
538 out.push_str("... [truncated]");
539 out
540 }
541}
542
543fn line_looks_like_credential_header(line: &str) -> bool {
545 let colon = match line.find(':') {
546 Some(i) => i,
547 None => return false,
548 };
549 let name = line[..colon].trim().to_ascii_lowercase();
550 if name.is_empty() {
551 return false;
552 }
553 if name == "authorization" || name == "private-token" {
554 return true;
555 }
556 if let Some(rest) = name.strip_prefix("x-") {
557 return rest == "token" || rest.ends_with("-token");
559 }
560 false
561}
562
563fn parse_next_link(link_header: &str) -> Option<String> {
564 for part in link_header.split(',') {
565 let trimmed = part.trim();
566 if trimmed.ends_with("rel=\"next\"")
567 && let Some(url) = trimmed.strip_suffix("; rel=\"next\"")
568 {
569 return Some(url.trim().trim_matches('<').trim_matches('>').to_string());
570 }
571 }
572 None
573}
574
575fn parse_datetime(s: &str) -> Result<chrono::DateTime<chrono::Utc>> {
578 chrono::DateTime::parse_from_rfc3339(s)
579 .map(|dt| dt.with_timezone(&chrono::Utc))
580 .map_err(|e| TrvError::ForgeApi(format!("Invalid datetime '{s}': {e}")))
581}
582
583fn default_list_prs_max() -> u32 {
589 30
590}
591
592fn cap_list_prs_max(m: u32) -> u32 {
593 m.clamp(1, 100)
595}
596
597fn convert_pr_list_item(
598 gh: GhPullRequestListItem,
599 current_user_login: Option<&str>,
600) -> Result<PrListItem> {
601 let state = if gh.merged_at.is_some() {
602 PrState::Merged
603 } else if gh.state == "closed" {
604 PrState::Closed
605 } else {
606 PrState::Open
607 };
608 let has_review_requested_from_me = match current_user_login {
609 Some(me) => gh.requested_reviewers.iter().any(|u| u.login == me),
610 None => false,
611 };
612 let comment_count = {
613 let review = gh.review_comments.unwrap_or(0);
614 let issue = gh.comments.unwrap_or(0);
615 u32::try_from(review.saturating_add(issue)).unwrap_or(u32::MAX)
616 };
617 let assignees = gh.assignees.iter().map(|u| u.login.clone()).collect();
618 let reviewers = gh
619 .requested_reviewers
620 .iter()
621 .map(|u| u.login.clone())
622 .collect();
623 Ok(PrListItem {
624 number: gh.number,
625 title: gh.title,
626 author: gh.user.login,
627 state,
628 is_draft: gh.draft.unwrap_or(false),
629 base_branch: gh.base.ref_name,
630 head_branch: gh.head.ref_name,
631 updated_at: parse_datetime(&gh.updated_at)?,
632 comment_count,
633 has_review_requested_from_me,
634 assignees,
635 reviewers,
636 })
637}
638
639fn convert_pr(gh: GhPullRequest) -> Result<PrMetadata> {
640 let state = if gh.merged_at.is_some() {
641 PrState::Merged
642 } else if gh.state == "closed" {
643 PrState::Closed
644 } else {
645 PrState::Open
646 };
647
648 let mergeable = gh.mergeable_state.as_deref().map(|s| match s {
649 "clean" => MergeableStatus::Clean,
650 "unstable" => MergeableStatus::Unstable,
651 "behind" => MergeableStatus::Behind,
652 "blocked" => MergeableStatus::Blocked,
653 "dirty" => MergeableStatus::Dirty,
654 "draft" => MergeableStatus::Draft,
655 _ => MergeableStatus::Unknown,
656 });
657
658 Ok(PrMetadata {
659 title: gh.title,
660 body: gh.body.unwrap_or_default(),
661 author: gh.user.login,
662 state,
663 base_branch: gh.base.ref_name,
664 head_branch: gh.head.ref_name,
665 head_sha: gh.head.sha,
666 created_at: parse_datetime(&gh.created_at)?,
667 mergeable,
668 is_draft: gh.draft.unwrap_or(false),
669 })
670}
671
672fn convert_commit(gh: GhCommit) -> Result<CommitInfo> {
673 let short_id = if gh.sha.len() >= 7 {
674 gh.sha[..7].to_string()
675 } else {
676 gh.sha.clone()
677 };
678 let (summary, body) = match gh.commit.message.split_once('\n') {
679 Some((s, b)) => (s.to_string(), Some(b.trim().to_string())),
680 None => (gh.commit.message.clone(), None),
681 };
682 let author = gh.author.map(|u| u.login).unwrap_or(gh.commit.author.name);
683 let time = match gh.commit.author.date {
684 Some(d) => parse_datetime(&d)?,
685 None => chrono::Utc::now(),
686 };
687
688 Ok(CommitInfo {
689 id: gh.sha,
690 short_id,
691 branch_name: None,
692 summary,
693 body,
694 author,
695 time,
696 })
697}
698
699fn convert_review_comment(gh: GhReviewComment) -> Result<RemoteComment> {
700 let side = gh.side.as_deref().map(|s| match s {
701 "LEFT" => LineSide::Old,
702 _ => LineSide::New,
703 });
704
705 Ok(RemoteComment {
706 id: gh.id,
707 author: gh.user.login,
708 body: gh.body,
709 path: Some(gh.path),
710 line: gh.line,
711 side,
712 created_at: parse_datetime(&gh.created_at)?,
713 in_reply_to: gh.in_reply_to_id,
714 })
715}
716
717fn parse_gh_files(gh_files: Vec<GhFile>) -> Vec<DiffFile> {
722 gh_files.into_iter().map(parse_single_gh_file).collect()
723}
724
725fn parse_single_gh_file(gh: GhFile) -> DiffFile {
726 let status = match gh.status.as_str() {
727 "added" => FileStatus::Added,
728 "removed" => FileStatus::Deleted,
729 "renamed" => FileStatus::Renamed,
730 "copied" => FileStatus::Copied,
731 _ => FileStatus::Modified,
732 };
733
734 let old_path = match status {
735 FileStatus::Added => None,
736 FileStatus::Renamed | FileStatus::Copied => {
737 gh.previous_filename.map(std::path::PathBuf::from)
738 }
739 _ => Some(std::path::PathBuf::from(&gh.filename)),
740 };
741
742 let new_path = match status {
743 FileStatus::Deleted => None,
744 _ => Some(std::path::PathBuf::from(&gh.filename)),
745 };
746
747 let hunks = match &gh.patch {
748 Some(patch) if !patch.is_empty() => parse_patch_hunks(patch),
749 _ => Vec::new(),
750 };
751
752 let is_binary = gh.patch.is_none()
753 && gh.additions == 0
754 && gh.deletions == 0
755 && status != FileStatus::Renamed
756 && status != FileStatus::Copied;
757 let is_too_large = gh.patch.is_none() && (gh.additions > 0 || gh.deletions > 0);
758
759 DiffFile {
760 old_path,
761 new_path,
762 status,
763 hunks,
764 is_binary,
765 is_too_large,
766 is_commit_message: false,
767 }
768}
769
770#[async_trait]
777impl ForgeRead for GitHubForge {
778 fn forge_type(&self) -> ForgeType {
779 ForgeType::GitHub
780 }
781
782 async fn get_pr(&self, id: &PrId) -> Result<PrMetadata> {
783 let path = format!("/repos/{}/{}/pulls/{}", id.owner, id.repo, id.number);
784 let gh: GhPullRequest = self.get(&path).await?;
785 convert_pr(gh)
786 }
787
788 async fn get_pr_commits(&self, id: &PrId) -> Result<Vec<CommitInfo>> {
789 let path = format!(
790 "/repos/{}/{}/pulls/{}/commits",
791 id.owner, id.repo, id.number
792 );
793 let gh: Vec<GhCommit> = self.get_all_pages(&path).await?;
794 gh.into_iter().map(convert_commit).collect()
795 }
796
797 async fn list_prs(
798 &self,
799 owner: &str,
800 repo: &str,
801 filter: &PrListFilter,
802 ) -> Result<Vec<PrListItem>> {
803 let limit = cap_list_prs_max(filter.max.unwrap_or(default_list_prs_max())) as usize;
804
805 let state_param = match filter.state {
807 Some(PrState::Closed) => "closed",
808 Some(PrState::Merged) => "closed", _ => "open",
810 };
811 let path =
812 format!("/repos/{owner}/{repo}/pulls?state={state_param}&sort=updated&direction=desc");
813
814 let rows: Vec<GhPullRequestListItem> = self.get_pages_up_to(&path, limit).await?;
815
816 let me = self.current_user().await.ok().map(|u| u.login);
821
822 let mut out: Vec<PrListItem> = rows
823 .into_iter()
824 .map(|row| convert_pr_list_item(row, me.as_deref()))
825 .collect::<Result<_>>()?;
826
827 if let Some(ref who) = filter.author {
833 out.retain(|r| r.author.eq_ignore_ascii_case(who));
834 }
835 if let Some(ref who) = filter.assignee {
836 out.retain(|r| r.assignees.iter().any(|a| a.eq_ignore_ascii_case(who)));
837 }
838 if let Some(true) = filter.review_requested {
839 out.retain(|r| r.has_review_requested_from_me);
840 }
841
842 Ok(out)
843 }
844
845 async fn get_pr_files(&self, id: &PrId) -> Result<Vec<DiffFile>> {
846 let path = format!("/repos/{}/{}/pulls/{}/files", id.owner, id.repo, id.number);
847 let gh: Vec<GhFile> = self.get_all_pages(&path).await?;
848 Ok(parse_gh_files(gh))
849 }
850
851 async fn get_commit_diff(&self, id: &PrId, commit_sha: &str) -> Result<Vec<DiffFile>> {
852 if !commit_sha.chars().all(|c| c.is_ascii_hexdigit()) {
853 return Err(TrvError::ForgeApi(format!(
854 "Invalid commit SHA: '{commit_sha}'"
855 )));
856 }
857 let path = format!("/repos/{}/{}/commits/{}", id.owner, id.repo, commit_sha);
858 let gh: GhCommitDetail2 = self.get(&path).await?;
859 Ok(parse_gh_files(gh.files.unwrap_or_default()))
860 }
861
862 async fn current_user(&self) -> Result<User> {
863 let gh: GhUser = self.get("/user").await?;
864 Ok(User {
865 login: gh.login,
866 id: gh.id,
867 })
868 }
869
870 async fn check_permissions(&self, id: &PrId) -> Result<Permissions> {
871 let path = format!("/repos/{}/{}", id.owner, id.repo);
872 let gh: GhRepo = self.get(&path).await?;
873
874 let perms = gh.permissions.unwrap_or(GhRepoPermissions {
875 push: None,
876 maintain: None,
877 admin: None,
878 });
879
880 let can_push = perms.push.unwrap_or(false)
881 || perms.maintain.unwrap_or(false)
882 || perms.admin.unwrap_or(false);
883 let can_merge = perms.maintain.unwrap_or(false) || perms.admin.unwrap_or(false);
884
885 let mut allowed_merge_methods = Vec::new();
886 if gh.allow_merge_commit.unwrap_or(true) {
887 allowed_merge_methods.push(MergeMethod::Merge);
888 }
889 if gh.allow_squash_merge.unwrap_or(true) {
890 allowed_merge_methods.push(MergeMethod::Squash);
891 }
892 if gh.allow_rebase_merge.unwrap_or(true) {
893 allowed_merge_methods.push(MergeMethod::Rebase);
894 }
895
896 Ok(Permissions {
897 can_push,
898 can_merge,
899 allowed_merge_methods,
900 })
901 }
902}
903
904#[async_trait]
905impl ForgeComments for GitHubForge {
906 async fn get_comments(&self, id: &PrId) -> Result<Vec<RemoteComment>> {
907 let review_path = format!(
909 "/repos/{}/{}/pulls/{}/comments",
910 id.owner, id.repo, id.number
911 );
912 let review_comments: Vec<GhReviewComment> = self.get_all_pages(&review_path).await?;
913
914 let issue_path = format!(
916 "/repos/{}/{}/issues/{}/comments",
917 id.owner, id.repo, id.number
918 );
919 let issue_comments: Vec<GhIssueComment> = self.get_all_pages(&issue_path).await?;
920
921 let mut all: Vec<RemoteComment> = review_comments
922 .into_iter()
923 .map(convert_review_comment)
924 .collect::<Result<Vec<_>>>()?;
925
926 for ic in issue_comments {
927 all.push(RemoteComment {
928 id: ic.id,
929 author: ic.user.login,
930 body: ic.body.unwrap_or_default(),
931 path: None,
932 line: None,
933 side: None,
934 created_at: parse_datetime(&ic.created_at)?,
935 in_reply_to: None,
936 });
937 }
938
939 all.sort_by_key(|c| c.created_at);
940 Ok(all)
941 }
942
943 async fn get_review_threads(&self, id: &PrId) -> Result<Vec<ReviewThread>> {
944 const MAX_PAGES: usize = 50;
945 let mut all_threads = Vec::new();
946 let mut cursor: Option<String> = None;
947
948 let query = r"query($owner: String!, $name: String!, $number: Int!, $after: String) {
949 repository(owner: $owner, name: $name) {
950 pullRequest(number: $number) {
951 reviewThreads(first: 100, after: $after) {
952 pageInfo { hasNextPage endCursor }
953 nodes {
954 id
955 isResolved
956 comments(first: 1) {
957 nodes { databaseId }
958 }
959 }
960 }
961 }
962 }
963}";
964
965 for _ in 0..MAX_PAGES {
966 let variables = serde_json::json!({
967 "owner": id.owner,
968 "name": id.repo,
969 "number": id.number,
970 "after": cursor,
971 });
972
973 let body = self.graphql_with_vars(query, variables).await?;
974
975 let review_threads = body
976 .pointer("/data/repository/pullRequest/reviewThreads")
977 .cloned()
978 .unwrap_or_default();
979
980 let nodes = review_threads
981 .get("nodes")
982 .and_then(|v| v.as_array())
983 .cloned()
984 .unwrap_or_default();
985
986 for t in nodes {
987 let thread_id = t
988 .get("id")
989 .and_then(|v| v.as_str())
990 .unwrap_or_default()
991 .to_string();
992 let is_resolved = t
993 .get("isResolved")
994 .and_then(serde_json::Value::as_bool)
995 .unwrap_or(false);
996 let root_comment_id = t
997 .pointer("/comments/nodes/0/databaseId")
998 .and_then(serde_json::Value::as_u64)
999 .unwrap_or(0);
1000
1001 all_threads.push(ReviewThread {
1002 id: thread_id,
1003 is_resolved,
1004 root_comment_id,
1005 });
1006 }
1007
1008 let has_next = review_threads
1009 .pointer("/pageInfo/hasNextPage")
1010 .and_then(serde_json::Value::as_bool)
1011 .unwrap_or(false);
1012
1013 if has_next {
1014 cursor = review_threads
1015 .pointer("/pageInfo/endCursor")
1016 .and_then(|v| v.as_str())
1017 .map(std::string::ToString::to_string);
1018 } else {
1019 break;
1020 }
1021 }
1022
1023 Ok(all_threads)
1024 }
1025
1026 async fn post_comment(&self, id: &PrId, comment: NewComment) -> Result<RemoteComment> {
1027 let commit_id = if let Some(sha) = &comment.commit_id {
1028 sha.clone()
1029 } else {
1030 let pr_path = format!("/repos/{}/{}/pulls/{}", id.owner, id.repo, id.number);
1031 let pr: GhPullRequest = self.get(&pr_path).await?;
1032 pr.head.sha
1033 };
1034 let path = format!(
1035 "/repos/{}/{}/pulls/{}/comments",
1036 id.owner, id.repo, id.number
1037 );
1038 let side = match comment.side {
1039 LineSide::Old => "LEFT",
1040 LineSide::New => "RIGHT",
1041 };
1042 let mut body = serde_json::json!({
1043 "path": comment.path,
1044 "body": comment.body,
1045 "line": comment.line,
1046 "side": side,
1047 "commit_id": commit_id,
1048 });
1049 if let Some(start) = comment.start_line {
1050 let start_side = match comment.side {
1051 LineSide::Old => "LEFT",
1052 LineSide::New => "RIGHT",
1053 };
1054 body["start_line"] = serde_json::json!(start);
1055 body["start_side"] = serde_json::json!(start_side);
1056 }
1057 let gh: GhReviewComment = self.post(&path, &body).await?;
1058 convert_review_comment(gh)
1059 }
1060
1061 async fn post_reply(&self, id: &PrId, thread_id: &str, body: &str) -> Result<RemoteComment> {
1062 let comment_id: u64 = thread_id.parse().map_err(|_| {
1064 TrvError::ForgeApi(format!(
1065 "Invalid comment ID for reply: '{thread_id}'. \
1066 Pass the root comment's numeric ID, not the GraphQL thread node ID."
1067 ))
1068 })?;
1069 let path = format!(
1070 "/repos/{}/{}/pulls/{}/comments",
1071 id.owner, id.repo, id.number
1072 );
1073 let req_body = serde_json::json!({
1074 "body": body,
1075 "in_reply_to": comment_id,
1076 });
1077 let gh: GhReviewComment = self.post(&path, &req_body).await?;
1078 convert_review_comment(gh)
1079 }
1080
1081 async fn edit_comment(&self, id: &PrId, comment_id: u64, body: &str) -> Result<RemoteComment> {
1082 let path = format!(
1083 "/repos/{}/{}/pulls/comments/{}",
1084 id.owner, id.repo, comment_id
1085 );
1086 let req_body = serde_json::json!({ "body": body });
1087 let gh: GhReviewComment = self.patch(&path, &req_body).await?;
1088 convert_review_comment(gh)
1089 }
1090
1091 async fn delete_comment(&self, id: &PrId, comment_id: u64) -> Result<()> {
1092 let path = format!(
1093 "/repos/{}/{}/pulls/comments/{}",
1094 id.owner, id.repo, comment_id
1095 );
1096 self.delete(&path).await
1097 }
1098
1099 async fn resolve_thread(&self, thread_id: &str) -> Result<()> {
1100 let query = r"mutation($threadId: ID!) {
1101 resolveReviewThread(input: { threadId: $threadId }) {
1102 thread { id isResolved }
1103 }
1104}";
1105 let variables = serde_json::json!({ "threadId": thread_id });
1106 self.graphql_with_vars(query, variables).await?;
1107 Ok(())
1108 }
1109
1110 async fn unresolve_thread(&self, thread_id: &str) -> Result<()> {
1111 let query = r"mutation($threadId: ID!) {
1112 unresolveReviewThread(input: { threadId: $threadId }) {
1113 thread { id isResolved }
1114 }
1115}";
1116 let variables = serde_json::json!({ "threadId": thread_id });
1117 self.graphql_with_vars(query, variables).await?;
1118 Ok(())
1119 }
1120}
1121
1122#[async_trait]
1123impl ForgeReview for GitHubForge {
1124 async fn submit_review(&self, id: &PrId, review: NewReview) -> Result<()> {
1125 let path = format!(
1126 "/repos/{}/{}/pulls/{}/reviews",
1127 id.owner, id.repo, id.number
1128 );
1129 let event = match review.verdict {
1130 ReviewVerdict::Approve => "APPROVE",
1131 ReviewVerdict::RequestChanges => "REQUEST_CHANGES",
1132 ReviewVerdict::Comment => "COMMENT",
1133 };
1134 let comments: Vec<serde_json::Value> = review
1135 .comments
1136 .into_iter()
1137 .map(|c| {
1138 let side = match c.side {
1139 LineSide::Old => "LEFT",
1140 LineSide::New => "RIGHT",
1141 };
1142 let mut obj = serde_json::json!({
1143 "path": c.path,
1144 "body": c.body,
1145 "line": c.line,
1146 "side": side,
1147 });
1148 if let Some(start) = c.start_line {
1149 obj["start_line"] = serde_json::json!(start);
1150 obj["start_side"] = serde_json::json!(side);
1151 }
1152 obj
1153 })
1154 .collect();
1155
1156 let body = serde_json::json!({
1157 "body": review.body,
1158 "event": event,
1159 "comments": comments,
1160 });
1161 self.post_no_content(&path, &body).await
1162 }
1163}
1164
1165#[async_trait]
1166impl ForgeMerge for GitHubForge {
1167 async fn merge(&self, id: &PrId, method: MergeMethod) -> Result<()> {
1168 let path = format!("/repos/{}/{}/pulls/{}/merge", id.owner, id.repo, id.number);
1169 let merge_method = match method {
1170 MergeMethod::Merge => "merge",
1171 MergeMethod::Squash => "squash",
1172 MergeMethod::Rebase => "rebase",
1173 };
1174 let body = serde_json::json!({ "merge_method": merge_method });
1175 self.put_no_content(&path, &body).await
1176 }
1177
1178 async fn close(&self, id: &PrId) -> Result<()> {
1179 let path = format!("/repos/{}/{}/pulls/{}", id.owner, id.repo, id.number);
1180 let body = serde_json::json!({ "state": "closed" });
1181 let _: serde_json::Value = self.patch(&path, &body).await?;
1182 Ok(())
1183 }
1184
1185 async fn reopen(&self, id: &PrId) -> Result<()> {
1186 let path = format!("/repos/{}/{}/pulls/{}", id.owner, id.repo, id.number);
1187 let body = serde_json::json!({ "state": "open" });
1188 let _: serde_json::Value = self.patch(&path, &body).await?;
1189 Ok(())
1190 }
1191}
1192
1193#[async_trait]
1194impl ForgeReactions for GitHubForge {
1195 async fn add_reaction(&self, target: &ReactionTarget, content: ReactionContent) -> Result<()> {
1196 match target {
1197 ReactionTarget::IssueComment(_) => {
1198 Err(TrvError::UnsupportedOperation(
1199 "add_reaction for IssueComment requires repository context not available in current trait".into(),
1200 ))
1201 }
1202 ReactionTarget::ReviewComment(_) => {
1203 Err(TrvError::UnsupportedOperation(
1204 "add_reaction for ReviewComment requires repository context not available in current trait".into(),
1205 ))
1206 }
1207 ReactionTarget::Review(node_id) => {
1208 let query = r"mutation($subjectId: ID!, $content: ReactionContent!) {
1209 addReaction(input: { subjectId: $subjectId, content: $content }) {
1210 reaction { content }
1211 }
1212}";
1213 let variables = serde_json::json!({
1214 "subjectId": node_id,
1215 "content": reaction_to_graphql(content),
1216 });
1217 self.graphql_with_vars(query, variables).await?;
1218 Ok(())
1219 }
1220 }
1221 }
1222
1223 async fn remove_reaction(
1224 &self,
1225 target: &ReactionTarget,
1226 content: ReactionContent,
1227 ) -> Result<()> {
1228 match target {
1229 ReactionTarget::IssueComment(_) | ReactionTarget::ReviewComment(_) => {
1230 Err(TrvError::UnsupportedOperation(
1231 "remove_reaction requires repository context not available in current trait"
1232 .into(),
1233 ))
1234 }
1235 ReactionTarget::Review(node_id) => {
1236 let query = r"mutation($subjectId: ID!, $content: ReactionContent!) {
1237 removeReaction(input: { subjectId: $subjectId, content: $content }) {
1238 reaction { content }
1239 }
1240}";
1241 let variables = serde_json::json!({
1242 "subjectId": node_id,
1243 "content": reaction_to_graphql(content),
1244 });
1245 self.graphql_with_vars(query, variables).await?;
1246 Ok(())
1247 }
1248 }
1249 }
1250}
1251
1252fn reaction_to_graphql(content: ReactionContent) -> &'static str {
1253 match content {
1254 ReactionContent::ThumbsUp => "THUMBS_UP",
1255 ReactionContent::ThumbsDown => "THUMBS_DOWN",
1256 ReactionContent::Laugh => "LAUGH",
1257 ReactionContent::Hooray => "HOORAY",
1258 ReactionContent::Confused => "CONFUSED",
1259 ReactionContent::Heart => "HEART",
1260 ReactionContent::Rocket => "ROCKET",
1261 ReactionContent::Eyes => "EYES",
1262 }
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267 use super::*;
1268 use crate::types::{GhCommitDetail, GhGitActor, GhRef};
1269
1270 #[test]
1271 fn parse_next_link_with_next() {
1272 let header = r#"<https://api.github.com/repos/o/r/pulls?page=2>; rel="next", <https://api.github.com/repos/o/r/pulls?page=5>; rel="last""#;
1273 assert_eq!(
1274 parse_next_link(header),
1275 Some("https://api.github.com/repos/o/r/pulls?page=2".to_string())
1276 );
1277 }
1278
1279 #[test]
1280 fn parse_next_link_without_next() {
1281 let header = r#"<https://api.github.com/repos/o/r/pulls?page=5>; rel="last""#;
1282 assert_eq!(parse_next_link(header), None);
1283 }
1284
1285 #[test]
1286 fn parse_next_link_empty() {
1287 assert_eq!(parse_next_link(""), None);
1288 }
1289
1290 #[test]
1291 fn parse_hunk_header_basic() {
1292 use travelagent_core::vcs::diff_parser::parse_hunk_header;
1293 assert_eq!(parse_hunk_header("@@ -1,3 +1,4 @@"), Some((1, 3, 1, 4)));
1294 }
1295
1296 #[test]
1297 fn parse_hunk_header_with_context() {
1298 use travelagent_core::vcs::diff_parser::parse_hunk_header;
1299 assert_eq!(
1300 parse_hunk_header("@@ -10,5 +20,8 @@ fn main()"),
1301 Some((10, 5, 20, 8))
1302 );
1303 }
1304
1305 #[test]
1306 fn parse_hunk_header_no_count() {
1307 use travelagent_core::vcs::diff_parser::parse_hunk_header;
1308 assert_eq!(parse_hunk_header("@@ -5 +10 @@"), Some((5, 1, 10, 1)));
1309 }
1310
1311 #[test]
1312 fn parse_patch_single_hunk() {
1313 use travelagent_core::model::LineOrigin;
1314 let patch = "@@ -1,3 +1,4 @@\n line1\n+added\n line2\n line3";
1315 let hunks = parse_patch_hunks(patch);
1316 assert_eq!(hunks.len(), 1);
1317 assert_eq!(hunks[0].lines.len(), 4);
1318 assert_eq!(hunks[0].lines[0].origin, LineOrigin::Context);
1319 assert_eq!(hunks[0].lines[1].origin, LineOrigin::Addition);
1320 assert_eq!(hunks[0].lines[1].content, "added");
1321 }
1322
1323 #[test]
1324 fn parse_patch_multiple_hunks() {
1325 let patch = "@@ -1,2 +1,3 @@\n a\n+b\n c\n@@ -10,1 +11,1 @@\n-old\n+new";
1326 let hunks = parse_patch_hunks(patch);
1327 assert_eq!(hunks.len(), 2);
1328 }
1329
1330 #[test]
1331 fn parse_patch_line_numbers() {
1332 let patch = "@@ -5,3 +5,4 @@\n ctx\n-del\n+add1\n+add2";
1333 let hunks = parse_patch_hunks(patch);
1334 let lines = &hunks[0].lines;
1335
1336 assert_eq!(lines[0].old_lineno, Some(5));
1337 assert_eq!(lines[0].new_lineno, Some(5));
1338 assert_eq!(lines[1].old_lineno, Some(6));
1339 assert_eq!(lines[1].new_lineno, None);
1340 assert_eq!(lines[2].old_lineno, None);
1341 assert_eq!(lines[2].new_lineno, Some(6));
1342 assert_eq!(lines[3].old_lineno, None);
1343 assert_eq!(lines[3].new_lineno, Some(7));
1344 }
1345
1346 #[test]
1347 fn parse_single_gh_file_added() {
1348 let gh = GhFile {
1349 filename: "new.rs".into(),
1350 status: "added".into(),
1351 additions: 5,
1352 deletions: 0,
1353 patch: Some("@@ -0,0 +1,2 @@\n+line1\n+line2".into()),
1354 previous_filename: None,
1355 };
1356 let df = parse_single_gh_file(gh);
1357 assert_eq!(df.status, FileStatus::Added);
1358 assert!(df.old_path.is_none());
1359 assert_eq!(df.new_path, Some(std::path::PathBuf::from("new.rs")));
1360 assert_eq!(df.hunks.len(), 1);
1361 }
1362
1363 #[test]
1364 fn parse_single_gh_file_renamed() {
1365 let gh = GhFile {
1366 filename: "new_name.rs".into(),
1367 status: "renamed".into(),
1368 additions: 0,
1369 deletions: 0,
1370 patch: None,
1371 previous_filename: Some("old_name.rs".into()),
1372 };
1373 let df = parse_single_gh_file(gh);
1374 assert_eq!(df.status, FileStatus::Renamed);
1375 assert_eq!(df.old_path, Some(std::path::PathBuf::from("old_name.rs")));
1376 assert_eq!(df.new_path, Some(std::path::PathBuf::from("new_name.rs")));
1377 assert!(!df.is_binary);
1378 }
1379
1380 #[test]
1381 fn parse_single_gh_file_binary() {
1382 let gh = GhFile {
1383 filename: "image.png".into(),
1384 status: "modified".into(),
1385 additions: 0,
1386 deletions: 0,
1387 patch: None,
1388 previous_filename: None,
1389 };
1390 let df = parse_single_gh_file(gh);
1391 assert!(df.is_binary);
1392 }
1393
1394 #[test]
1395 fn convert_pr_open() {
1396 let gh = GhPullRequest {
1397 number: 1,
1398 title: "test".into(),
1399 body: Some("body".into()),
1400 state: "open".into(),
1401 draft: Some(false),
1402 merged_at: None,
1403 user: GhUser {
1404 login: "alice".into(),
1405 id: 1,
1406 },
1407 base: GhRef {
1408 ref_name: "main".into(),
1409 sha: "abc".into(),
1410 },
1411 head: GhRef {
1412 ref_name: "feat".into(),
1413 sha: "def".into(),
1414 },
1415 created_at: "2024-01-01T00:00:00Z".into(),
1416 mergeable_state: Some("clean".into()),
1417 mergeable: Some(true),
1418 };
1419 let pr = convert_pr(gh).unwrap();
1420 assert_eq!(pr.state, PrState::Open);
1421 assert_eq!(pr.mergeable, Some(MergeableStatus::Clean));
1422 assert!(!pr.is_draft);
1423 }
1424
1425 #[test]
1426 fn convert_pr_merged() {
1427 let gh = GhPullRequest {
1428 number: 2,
1429 title: "merged".into(),
1430 body: None,
1431 state: "closed".into(),
1432 draft: None,
1433 merged_at: Some("2024-01-02T00:00:00Z".into()),
1434 user: GhUser {
1435 login: "bob".into(),
1436 id: 2,
1437 },
1438 base: GhRef {
1439 ref_name: "main".into(),
1440 sha: "abc".into(),
1441 },
1442 head: GhRef {
1443 ref_name: "fix".into(),
1444 sha: "def".into(),
1445 },
1446 created_at: "2024-01-01T00:00:00Z".into(),
1447 mergeable_state: None,
1448 mergeable: None,
1449 };
1450 let pr = convert_pr(gh).unwrap();
1451 assert_eq!(pr.state, PrState::Merged);
1452 assert_eq!(pr.body, "");
1453 }
1454
1455 #[test]
1456 fn convert_pr_list_item_open_pr_no_reviewers() {
1457 let gh = GhPullRequestListItem {
1458 number: 1,
1459 title: "feat: stuff".into(),
1460 state: "open".into(),
1461 draft: Some(false),
1462 merged_at: None,
1463 user: GhUser {
1464 login: "alice".into(),
1465 id: 1,
1466 },
1467 base: GhRef {
1468 ref_name: "main".into(),
1469 sha: "abc".into(),
1470 },
1471 head: GhRef {
1472 ref_name: "feat".into(),
1473 sha: "def".into(),
1474 },
1475 updated_at: "2024-06-15T10:30:00Z".into(),
1476 requested_reviewers: vec![],
1477 assignees: vec![],
1478 comments: Some(2),
1479 review_comments: Some(3),
1480 };
1481 let item = convert_pr_list_item(gh, Some("bob")).unwrap();
1482 assert_eq!(item.number, 1);
1483 assert_eq!(item.state, PrState::Open);
1484 assert_eq!(item.author, "alice");
1485 assert_eq!(item.comment_count, 5);
1486 assert!(!item.is_draft);
1487 assert!(!item.has_review_requested_from_me);
1488 }
1489
1490 #[test]
1491 fn convert_pr_list_item_merged_state_detected_via_merged_at() {
1492 let gh = GhPullRequestListItem {
1493 number: 2,
1494 title: "chore".into(),
1495 state: "closed".into(),
1496 draft: None,
1497 merged_at: Some("2024-06-16T12:00:00Z".into()),
1498 user: GhUser {
1499 login: "bob".into(),
1500 id: 2,
1501 },
1502 base: GhRef {
1503 ref_name: "main".into(),
1504 sha: "a".into(),
1505 },
1506 head: GhRef {
1507 ref_name: "f".into(),
1508 sha: "b".into(),
1509 },
1510 updated_at: "2024-06-16T12:00:00Z".into(),
1511 requested_reviewers: vec![],
1512 assignees: vec![],
1513 comments: None,
1514 review_comments: None,
1515 };
1516 let item = convert_pr_list_item(gh, None).unwrap();
1517 assert_eq!(item.state, PrState::Merged);
1518 assert_eq!(item.comment_count, 0);
1519 }
1520
1521 #[test]
1522 fn convert_pr_list_item_flags_review_requested_for_current_user() {
1523 let gh = GhPullRequestListItem {
1524 number: 3,
1525 title: "fix".into(),
1526 state: "open".into(),
1527 draft: Some(true),
1528 merged_at: None,
1529 user: GhUser {
1530 login: "alice".into(),
1531 id: 1,
1532 },
1533 base: GhRef {
1534 ref_name: "main".into(),
1535 sha: "a".into(),
1536 },
1537 head: GhRef {
1538 ref_name: "f".into(),
1539 sha: "b".into(),
1540 },
1541 updated_at: "2024-06-15T10:30:00Z".into(),
1542 requested_reviewers: vec![
1543 GhUser {
1544 login: "carol".into(),
1545 id: 3,
1546 },
1547 GhUser {
1548 login: "bob".into(),
1549 id: 2,
1550 },
1551 ],
1552 assignees: vec![],
1553 comments: Some(0),
1554 review_comments: Some(0),
1555 };
1556 let item = convert_pr_list_item(gh, Some("bob")).unwrap();
1557 assert!(item.is_draft);
1558 assert!(item.has_review_requested_from_me);
1559 assert_eq!(item.reviewers, vec!["carol".to_string(), "bob".to_string()]);
1560 }
1561
1562 #[test]
1563 fn cap_list_prs_max_clamps_to_one_and_hundred() {
1564 assert_eq!(cap_list_prs_max(0), 1);
1565 assert_eq!(cap_list_prs_max(30), 30);
1566 assert_eq!(cap_list_prs_max(1000), 100);
1567 }
1568
1569 #[test]
1570 fn convert_commit_basic() {
1571 let gh = GhCommit {
1572 sha: "abc123def456".into(),
1573 commit: GhCommitDetail {
1574 message: "Fix bug\n\nDetailed description".into(),
1575 author: GhGitActor {
1576 name: "Alice".into(),
1577 date: Some("2024-01-01T00:00:00Z".into()),
1578 },
1579 },
1580 author: Some(GhUser {
1581 login: "alice".into(),
1582 id: 1,
1583 }),
1584 };
1585 let ci = convert_commit(gh).unwrap();
1586 assert_eq!(ci.id, "abc123def456");
1587 assert_eq!(ci.short_id, "abc123d");
1588 assert_eq!(ci.summary, "Fix bug");
1589 assert_eq!(ci.body, Some("Detailed description".into()));
1590 assert_eq!(ci.author, "alice");
1591 }
1592
1593 #[test]
1594 fn graphql_url_github_com() {
1595 let forge = GitHubForge {
1596 client: Client::new(),
1597 base_url: "https://api.github.com".into(),
1598 warn_handler: None,
1599 };
1600 assert_eq!(forge.graphql_url(), "https://api.github.com/graphql");
1601 }
1602
1603 #[test]
1604 fn graphql_url_ghe() {
1605 let forge = GitHubForge {
1606 client: Client::new(),
1607 base_url: "https://github.example.com/api/v3".into(),
1608 warn_handler: None,
1609 };
1610 assert_eq!(
1611 forge.graphql_url(),
1612 "https://github.example.com/api/graphql"
1613 );
1614 }
1615
1616 #[test]
1619 fn validate_base_url_accepts_https_domain() {
1620 let got = GitHubForge::validate_base_url("https://api.github.com", false).unwrap();
1621 assert_eq!(got, "https://api.github.com");
1622 }
1623
1624 #[test]
1625 fn validate_base_url_accepts_ghe_with_trailing_slash() {
1626 let got =
1627 GitHubForge::validate_base_url("https://github.example.com/api/v3/", false).unwrap();
1628 assert_eq!(got, "https://github.example.com/api/v3");
1629 }
1630
1631 #[test]
1632 fn validate_base_url_rejects_http_scheme() {
1633 let err = GitHubForge::validate_base_url("http://api.github.com", false).unwrap_err();
1634 assert!(matches!(err, TrvError::AuthError(_)));
1635 }
1636
1637 #[test]
1638 fn validate_base_url_rejects_non_url() {
1639 let err = GitHubForge::validate_base_url("not a url", false).unwrap_err();
1640 assert!(matches!(err, TrvError::AuthError(_)));
1641 }
1642
1643 #[test]
1644 fn validate_base_url_rejects_loopback_v4() {
1645 let err = GitHubForge::validate_base_url("https://127.0.0.1", false).unwrap_err();
1646 assert!(matches!(err, TrvError::AuthError(_)));
1647 }
1648
1649 #[test]
1650 fn validate_base_url_rejects_private_v4() {
1651 for url in [
1652 "https://10.0.0.1",
1653 "https://192.168.1.1",
1654 "https://172.16.0.1",
1655 ] {
1656 let err = GitHubForge::validate_base_url(url, false).unwrap_err();
1657 assert!(
1658 matches!(err, TrvError::AuthError(_)),
1659 "expected AuthError for {url}"
1660 );
1661 }
1662 }
1663
1664 #[test]
1665 fn validate_base_url_rejects_link_local_v4() {
1666 let err = GitHubForge::validate_base_url("https://169.254.169.254", false).unwrap_err();
1667 assert!(matches!(err, TrvError::AuthError(_)));
1668 }
1669
1670 #[test]
1671 fn validate_base_url_rejects_unspecified_v4() {
1672 let err = GitHubForge::validate_base_url("https://0.0.0.0", false).unwrap_err();
1673 assert!(matches!(err, TrvError::AuthError(_)));
1674 }
1675
1676 #[test]
1677 fn validate_base_url_rejects_loopback_v6() {
1678 let err = GitHubForge::validate_base_url("https://[::1]", false).unwrap_err();
1679 assert!(matches!(err, TrvError::AuthError(_)));
1680 }
1681
1682 #[test]
1683 fn validate_base_url_rejects_link_local_v6() {
1684 let err = GitHubForge::validate_base_url("https://[fe80::1]", false).unwrap_err();
1685 assert!(matches!(err, TrvError::AuthError(_)));
1686 }
1687
1688 #[test]
1689 fn validate_base_url_allows_loopback_when_insecure() {
1690 let got = GitHubForge::validate_base_url("http://127.0.0.1:8080", true).unwrap();
1691 assert_eq!(got, "http://127.0.0.1:8080");
1692 }
1693
1694 #[test]
1695 fn with_token_insecure_allows_loopback_http() {
1696 let forge = GitHubForge::with_token_insecure("http://127.0.0.1:1234", "tok".into());
1697 assert!(forge.is_ok());
1698 }
1699
1700 #[test]
1701 fn is_blocked_ip_covers_cgnat() {
1702 let ip: IpAddr = "100.64.0.1".parse().unwrap();
1703 assert!(is_blocked_ip(&ip));
1704 let ip: IpAddr = "100.127.255.255".parse().unwrap();
1705 assert!(is_blocked_ip(&ip));
1706 let ip: IpAddr = "100.128.0.1".parse().unwrap();
1708 assert!(!is_blocked_ip(&ip));
1709 }
1710
1711 #[test]
1714 fn with_token_builds_client_with_timeouts() {
1715 let forge = GitHubForge::with_token("https://api.github.com", "tok".into());
1718 assert!(forge.is_ok());
1719 }
1720
1721 #[test]
1724 fn with_token_invalid_bytes_returns_static_message() {
1725 let bad_token = "abc\ndef".to_string();
1728 let result = GitHubForge::with_token("https://api.github.com", bad_token);
1729 let err = match result {
1730 Err(e) => e,
1731 Ok(_) => panic!("expected with_token to fail for invalid header bytes"),
1732 };
1733 match err {
1734 TrvError::AuthError(msg) => {
1735 assert_eq!(
1736 msg,
1737 "token contains invalid header bytes; check GITHUB_TOKEN"
1738 );
1739 assert!(!msg.contains("abc"));
1740 assert!(!msg.contains("def"));
1741 }
1742 other => panic!("expected AuthError, got {other:?}"),
1743 }
1744 }
1745
1746 #[test]
1749 fn sanitize_error_body_strips_authorization_line() {
1750 let body = "Something went wrong\nAuthorization: Bearer abc123\nmore text";
1751 let got = sanitize_error_body(body);
1752 assert!(!got.to_ascii_lowercase().contains("authorization"));
1753 assert!(!got.contains("abc123"));
1754 assert!(got.contains("Something went wrong"));
1755 assert!(got.contains("more text"));
1756 }
1757
1758 #[test]
1759 fn sanitize_error_body_strips_private_token_case_insensitive() {
1760 let body = "prefix\nprivate-token: glpat-abc\nPRIVATE-TOKEN: other\nsuffix";
1761 let got = sanitize_error_body(body);
1762 assert!(!got.contains("glpat-abc"));
1763 assert!(!got.contains("other"));
1764 assert!(got.contains("prefix"));
1765 assert!(got.contains("suffix"));
1766 }
1767
1768 #[test]
1769 fn sanitize_error_body_strips_x_token_headers() {
1770 let body = "line1\nx-github-token: ghp_secret\nx-gitlab-token: tok2\nline2";
1771 let got = sanitize_error_body(body);
1772 assert!(!got.contains("ghp_secret"));
1773 assert!(!got.contains("tok2"));
1774 assert!(got.contains("line1"));
1775 assert!(got.contains("line2"));
1776 }
1777
1778 #[test]
1779 fn sanitize_error_body_truncates_large_payload() {
1780 let body = "a".repeat(10_000);
1781 let got = sanitize_error_body(&body);
1782 assert!(got.len() <= MAX_ERROR_BODY_BYTES + "... [truncated]".len());
1783 assert!(got.ends_with("... [truncated]"));
1784 }
1785
1786 #[test]
1787 fn sanitize_error_body_preserves_short_body() {
1788 let body = "{\"message\":\"Not Found\"}";
1789 let got = sanitize_error_body(body);
1790 assert_eq!(got, body);
1791 }
1792
1793 #[test]
1794 fn line_looks_like_credential_header_cases() {
1795 assert!(line_looks_like_credential_header(
1796 "Authorization: Bearer abc"
1797 ));
1798 assert!(line_looks_like_credential_header("authorization:x"));
1799 assert!(line_looks_like_credential_header("Private-Token: abc"));
1800 assert!(line_looks_like_credential_header("X-GitHub-Token: abc"));
1801 assert!(line_looks_like_credential_header("x-token: abc"));
1802 assert!(!line_looks_like_credential_header("Content-Type: json"));
1804 assert!(!line_looks_like_credential_header("message: bad request"));
1805 assert!(!line_looks_like_credential_header("no colon here"));
1806 assert!(!line_looks_like_credential_header("X-Request-Id: abc"));
1807 }
1808
1809 #[test]
1812 fn emit_warning_routes_through_handler_when_installed() {
1813 use std::sync::{Arc, Mutex};
1814 let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
1815 let sink = Arc::clone(&captured);
1816 let forge = GitHubForge::with_token("https://api.github.com", "tok".into())
1817 .unwrap()
1818 .with_warn_handler(Arc::new(move |msg| {
1819 sink.lock().unwrap().push(msg);
1820 }));
1821 forge.emit_warning("hello".to_string());
1822 let got = captured.lock().unwrap().clone();
1823 assert_eq!(got, vec!["hello".to_string()]);
1824 }
1825
1826 #[test]
1827 fn emit_warning_falls_back_without_handler() {
1828 let forge = GitHubForge::with_token("https://api.github.com", "tok".into()).unwrap();
1832 assert!(forge.warn_handler.is_none());
1833 forge.emit_warning("fallback".to_string());
1834 }
1835}