Skip to main content

travelagent_forge_github/
client.rs

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
26/// Maximum number of bytes from a response body we retain when building error
27/// messages. Bodies beyond this are truncated so we never splat multi-megabyte
28/// payloads (potentially containing secrets) into logs.
29const MAX_ERROR_BODY_BYTES: usize = 2 * 1024;
30
31/// Hard connect timeout for outbound HTTP requests.
32const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
33/// Hard overall request timeout. Protects the TUI from hanging on slow forges.
34const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
35
36pub struct GitHubForge {
37    client: Client,
38    base_url: String,
39    /// Optional callback for non-fatal warnings (e.g. pagination truncation).
40    /// When `None`, warnings fall back to stderr via `eprintln!`. The TUI
41    /// installs one of these via `with_warn_handler` so warnings reach the
42    /// status bar + error-log ring instead of garbling the alternate screen.
43    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    /// Validate + normalize a base URL. Rejects non-`https` schemes and
52    /// hostnames that resolve (syntactically) to loopback, private, link-local,
53    /// or unspecified IP literals. Pass `allow_insecure = true` to bypass the
54    /// scheme and IP-literal checks (useful for tests hitting `http://127.0.0.1`
55    /// mock servers).
56    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        // Return the original (trimmed) string so downstream path joins continue
101        // to work against the caller's exact formatting (e.g. GHE `/api/v3`).
102        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        // Production builder: require https + public host.
112        Self::with_token_validated(base_url, token, false)
113    }
114
115    /// Like [`with_token`] but allows the caller to opt into insecure hosts
116    /// (non-https, loopback/private IP literals). Production callers should
117    /// stick with [`with_token`].
118    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        // Set overall + connect timeouts to bound worst-case latency.
148        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    /// Install a callback to receive non-fatal warnings (e.g. pagination
163    /// truncation). Without one, warnings fall back to `eprintln!`.
164    pub fn with_warn_handler(mut self, handler: ForgeWarnHandler) -> Self {
165        self.warn_handler = Some(handler);
166        self
167    }
168
169    /// Emit a non-fatal warning through the installed callback, or fall back
170    /// to `eprintln!` when no callback is set.
171    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    /// Non-consuming status check for use in `get_all_pages` where we need to
271    /// read headers (Link) from the response before consuming the body.
272    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                // Phase A: surface silent truncation via the installed warn
332                // handler (TUI routes this into the status bar + error-log
333                // ring). Without a handler we fall back to stderr so CLI-only
334                // paths still see the message.
335                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    /// Paginate `path` up to `limit` items, using `per_page` up to 100 per
372    /// request. Stops as soon as the accumulated count reaches `limit` or the
373    /// next Link header is absent. Returns at most `limit` items.
374    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    /// GraphQL endpoint URL. For github.com the REST base is https://api.github.com
432    /// and GraphQL is at https://api.github.com/graphql. For GHE, the REST base is
433    /// https://HOSTNAME/api/v3 and GraphQL is https://HOSTNAME/api/graphql.
434    fn graphql_url(&self) -> String {
435        if self.base_url.ends_with("/api/v3") {
436            // GHE: https://HOSTNAME/api/v3 -> https://HOSTNAME/api/graphql
437            format!("{}/graphql", self.base_url.trim_end_matches("/v3"))
438        } else {
439            // github.com: https://api.github.com -> https://api.github.com/graphql
440            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
473/// Return true when `ip` falls into a range we refuse to reach from the forge
474/// client (SSRF hardening): loopback, private (RFC1918), link-local, or
475/// unspecified addresses.
476fn 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                // 100.64.0.0/10 — RFC 6598 carrier-grade NAT
486                || (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64)
487        }
488        IpAddr::V6(v6) => {
489            // Unique local (fc00::/7)
490            (v6.segments()[0] & 0xfe00) == 0xfc00
491                // Link-local (fe80::/10)
492                || (v6.segments()[0] & 0xffc0) == 0xfe80
493                // IPv4-mapped / IPv4-compatible — re-check the embedded v4.
494                || v6
495                    .to_ipv4_mapped()
496                    .is_some_and(|v4| is_blocked_ip(&IpAddr::V4(v4)))
497        }
498    }
499}
500
501/// Read the body of an error response, surfacing the read failure instead
502/// of silently returning an empty string. Previously `check_response`
503/// called `resp.text().await.unwrap_or_default()`, which dropped the
504/// underlying IO error and left `sanitize_error_body` operating on "" —
505/// users saw "GitHub API 500 for <url>: " with no hint at what went
506/// wrong. grok-4-20-thinking flagged this in the 2026-05-02 crew review.
507async 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
514/// Strip lines that look like credential headers (Authorization, Private-Token,
515/// X-*-Token, etc.) and truncate to [`MAX_ERROR_BODY_BYTES`]. We never want a
516/// forge echoing a request header into an error body to leak a token into
517/// TUI/log output.
518fn sanitize_error_body(body: &str) -> String {
519    // Filter credential-bearing lines first, so truncation operates on the
520    // scrubbed text (avoids a case where we truncate right before a secret
521    // header and happen to keep it).
522    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        // Truncate on a char boundary to avoid splitting a multi-byte UTF-8
532        // scalar in half.
533        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
543/// True if `line` matches `(?i)^(authorization|private-token|x-.*-token)\s*:`.
544fn 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        // Anything ending in "-token" or exactly "token".
558        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
575// --- Conversion helpers ---
576
577fn 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
583/// Bounded GET that paginates up to `limit` items or `MAX_PAGES` pages.
584///
585/// Returns at most `limit` items, stopping as soon as that threshold is met
586/// even if more pages exist. Used by `list_prs` so callers can ask for a
587/// small number (default 30) without us fetching everything.
588fn default_list_prs_max() -> u32 {
589    30
590}
591
592fn cap_list_prs_max(m: u32) -> u32 {
593    // Cap at 100 per spec; also clamp to at least 1.
594    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
717/// Parse GitHub's per-file `patch` field into DiffFile structures.
718///
719/// GitHub returns a `patch` string per file that looks like a unified diff
720/// without the `---`/`+++` file headers. Each hunk starts with `@@`.
721fn 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// --- ForgeBackend implementation ---
771//
772// Split into one `impl` block per capability trait (Phase H5). The
773// supertrait alias `ForgeBackend` is auto-derived via the blanket impl in
774// `travelagent_core::forge`.
775
776#[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        // Map our PrState into GitHub's `state` query param.
806        let state_param = match filter.state {
807            Some(PrState::Closed) => "closed",
808            Some(PrState::Merged) => "closed", // GitHub lumps merged into closed
809            _ => "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        // Look up current user once so we can flag review-requested rows. Fail soft
817        // — if the /user call errors, we just leave the flag as false rather than
818        // failing the whole list fetch. A valid token may lack `user:read` but
819        // still be able to read a repo's PRs.
820        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        // Client-side filters. GitHub's list endpoint has no server-side
828        // assignee filter, but PR #138 wired `assignees` into `PrListItem`,
829        // so we can honour `filter.assignee` here without a richer path.
830        // Comparisons are case-insensitive to match the TUI picker and
831        // tolerate GitHub's mixed-case login echoes.
832        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        // Fetch inline review comments
908        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        // Fetch general issue comments
915        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        // thread_id should be the root comment's numeric database ID
1063        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    // --- SSRF / base_url validation ---
1617
1618    #[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        // Just outside CGNAT
1707        let ip: IpAddr = "100.128.0.1".parse().unwrap();
1708        assert!(!is_blocked_ip(&ip));
1709    }
1710
1711    // --- HTTP client builder timeouts ---
1712
1713    #[test]
1714    fn with_token_builds_client_with_timeouts() {
1715        // The reqwest::Client API doesn't expose timeouts for read-back, so we
1716        // exercise the code path and assert the builder succeeds with them set.
1717        let forge = GitHubForge::with_token("https://api.github.com", "tok".into());
1718        assert!(forge.is_ok());
1719    }
1720
1721    // --- Invalid token error message ---
1722
1723    #[test]
1724    fn with_token_invalid_bytes_returns_static_message() {
1725        // \n is not allowed in HTTP header values; the builder must reject it
1726        // without echoing the underlying cause (which may contain the token).
1727        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    // --- Error body sanitizer ---
1747
1748    #[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        // Not a credential header:
1803        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    // --- Warn handler routing ---
1810
1811    #[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        // Without a handler installed, emit_warning routes to stderr via
1829        // eprintln!. We can't capture stderr here, but exercising the code
1830        // path guarantees it doesn't panic / deadlock.
1831        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}