Skip to main content

mcp_methods/
github.rs

1use regex::Regex;
2use serde_json::{json, Value};
3use std::collections::{HashMap, HashSet};
4use std::env;
5use std::sync::LazyLock;
6use std::time::Duration;
7
8use crate::compact;
9use crate::git_refs;
10
11// ---------------------------------------------------------------------------
12// Constants
13// ---------------------------------------------------------------------------
14
15const GITHUB_API: &str = "https://api.github.com";
16pub const OVERFLOW_LIMIT: usize = 100_000;
17pub const OVERFLOW_PREVIEW: usize = 40_000;
18const MAX_RELATED: usize = 10;
19/// Comment pages: first 5 + last 5 (30 per page → 300 comments max, skipping middle).
20const COMMENT_HEAD_PAGES: usize = 5;
21const COMMENT_TAIL_PAGES: usize = 5;
22/// Timeline pages: first 3 + last 2 (enough for cross-refs + recent activity).
23const TIMELINE_HEAD_PAGES: usize = 3;
24const TIMELINE_TAIL_PAGES: usize = 2;
25
26static URL_RE: LazyLock<Regex> =
27    LazyLock::new(|| Regex::new(r"^https://github\.com/([^/]+/[^/]+)/").unwrap());
28
29static GIT_SSH_RE: LazyLock<Regex> =
30    LazyLock::new(|| Regex::new(r"^git@github\.com:([^/]+/[^/]+?)(?:\.git)?$").unwrap());
31
32static GIT_HTTPS_RE: LazyLock<Regex> =
33    LazyLock::new(|| Regex::new(r"^https?://github\.com/([^/]+/[^/]+?)(?:\.git)?$").unwrap());
34
35/// Shared HTTP agent with connection pooling (keep-alive).
36static AGENT: LazyLock<ureq::Agent> = LazyLock::new(|| {
37    ureq::AgentBuilder::new()
38        .timeout(Duration::from_secs(30))
39        .build()
40});
41
42// ---------------------------------------------------------------------------
43// Retry / backoff for transient GitHub API failures
44// ---------------------------------------------------------------------------
45//
46// Design ported from KGLite's shared dataset HTTP client
47// (`crates/kglite/src/datasets/http.rs`, 2026-07-08): retry transient
48// failures (429 / 5xx / transport errors) with exponential backoff and
49// bail immediately on non-transient statuses so the caller's existing
50// error-message mapping fires on the first attempt's response. Without
51// this a single transient GitHub 5xx or network blip fails a tool call
52// outright.
53
54/// Retries after the first attempt (total attempts = `RETRY_COUNT + 1`).
55const RETRY_COUNT: usize = 3;
56/// Initial backoff before the first retry; doubles each subsequent retry.
57const BASE_BACKOFF_MS: u64 = 500;
58/// Ceiling for a single backoff sleep between retries.
59const MAX_BACKOFF_MS: u64 = 30_000;
60
61/// Is this HTTP status worth retrying? Transient = 429 (rate limited)
62/// and 5xx (server) statuses. Non-transient 4xx (401 / 403 / 404 / 422)
63/// bubble immediately — a retry can't change the outcome, and each call
64/// site's error mapping (rate-limit hint, "Not found", auth message)
65/// must fire on the first response.
66///
67/// GitHub returns **403** for rate limiting (with a body hint), but we
68/// deliberately do NOT retry it: the existing message tells the agent to
69/// set a token, and a retry with the same (missing) token would only
70/// delay that guidance. Primary rate limits reset on an hourly window,
71/// far beyond our ~7 s total backoff budget, so retrying would not
72/// recover within the request anyway.
73fn status_is_transient(code: u16) -> bool {
74    code == 429 || (500..=599).contains(&code)
75}
76
77/// Classify a raw ureq error. Transport errors (DNS / connect / TLS /
78/// timeout) are always transient; status errors defer to
79/// [`status_is_transient`].
80fn is_transient(err: &ureq::Error) -> bool {
81    match err {
82        ureq::Error::Status(code, _) => status_is_transient(*code),
83        ureq::Error::Transport(_) => true,
84    }
85}
86
87/// Next backoff delay: double, capped at [`MAX_BACKOFF_MS`].
88fn next_backoff_ms(current: u64) -> u64 {
89    current.saturating_mul(2).min(MAX_BACKOFF_MS)
90}
91
92/// Run a GET-shaped request with retry on transient failures.
93///
94/// `build` must construct **and send** a fresh request on each call
95/// (ureq requests are consumed by `.call()`), returning ureq's raw
96/// `Result<Response, Error>`. Retries transient errors (see
97/// [`is_transient`]) with exponential backoff (`BASE_BACKOFF_MS × 2^n`,
98/// capped at `MAX_BACKOFF_MS`); the final attempt's result — success or
99/// error — is returned verbatim, so each call site's existing status /
100/// error mapping stays intact and the retry is transparent when the
101/// first attempt succeeds.
102///
103/// GET / idempotent requests only — never wrap a POST or mutation.
104// `ureq::Error` is a large enum (~272 B); returning it in a `Result`
105// trips `result_large_err`. Boxing it would force every call site's
106// `ureq::Error::Status(code, resp)` match arm into a deref pattern, so
107// we accept the large `Err` variant at this ureq API boundary instead.
108#[allow(clippy::result_large_err)]
109fn call_with_retry<F>(build: F) -> Result<ureq::Response, ureq::Error>
110where
111    F: Fn() -> Result<ureq::Response, ureq::Error>,
112{
113    let mut delay_ms = BASE_BACKOFF_MS;
114    for attempt in 0..=RETRY_COUNT {
115        match build() {
116            Ok(resp) => return Ok(resp),
117            Err(e) => {
118                if !is_transient(&e) || attempt == RETRY_COUNT {
119                    return Err(e);
120                }
121                std::thread::sleep(Duration::from_millis(delay_ms));
122                delay_ms = next_backoff_ms(delay_ms);
123            }
124        }
125    }
126    unreachable!("call_with_retry loop returns or errors before completing")
127}
128
129/// Rough byte-size estimate for a serde_json::Value without allocating a string.
130pub fn estimate_json_size(val: &Value) -> usize {
131    match val {
132        Value::Null => 4,
133        Value::Bool(b) => {
134            if *b {
135                4
136            } else {
137                5
138            }
139        }
140        Value::Number(n) => {
141            // Rough: number of digits
142            let s = n.to_string();
143            s.len()
144        }
145        Value::String(s) => s.len() + 2, // quotes
146        Value::Array(arr) => 2 + arr.iter().map(|v| estimate_json_size(v) + 1).sum::<usize>(),
147        Value::Object(map) => {
148            2 + map
149                .iter()
150                .map(|(k, v)| k.len() + 3 + estimate_json_size(v) + 1)
151                .sum::<usize>()
152        }
153    }
154}
155
156// ---------------------------------------------------------------------------
157// Token / auth
158// ---------------------------------------------------------------------------
159
160fn auth_token() -> Option<String> {
161    // `env::var` returns Ok("") for an env var set to the empty string,
162    // which would mark `has_git_token()` as `true` and let the github
163    // tools register, only to 401 on the first call. Filter empties so
164    // operators can clear the token by setting it to "" without having
165    // to delete the binding entirely.
166    env::var("GITHUB_TOKEN")
167        .or_else(|_| env::var("GH_TOKEN"))
168        .ok()
169        .filter(|s| !s.is_empty())
170}
171
172/// Check if a GitHub token is available in the environment.
173pub fn has_git_token() -> bool {
174    auth_token().is_some()
175}
176
177/// Auto-detect `org/repo` from the git remote in *cwd*.
178pub fn detect_git_repo(cwd: &str) -> Option<String> {
179    let output = std::process::Command::new("git")
180        .args(["remote", "get-url", "origin"])
181        .current_dir(cwd)
182        .output()
183        .ok()?;
184
185    if !output.status.success() {
186        return None;
187    }
188
189    let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
190
191    if let Some(cap) = GIT_SSH_RE.captures(&url) {
192        return Some(cap[1].to_string());
193    }
194    if let Some(cap) = GIT_HTTPS_RE.captures(&url) {
195        return Some(cap[1].to_string());
196    }
197    None
198}
199
200// ---------------------------------------------------------------------------
201// HTTP helpers
202// ---------------------------------------------------------------------------
203
204// Closure below returns ureq's large `Result` at the `.call()` boundary.
205#[allow(clippy::result_large_err)]
206pub(crate) fn gh_get(endpoint: &str) -> Result<Value, String> {
207    let url = if endpoint.starts_with("http") {
208        endpoint.to_string()
209    } else {
210        format!("{}/{}", GITHUB_API, endpoint)
211    };
212
213    let result = call_with_retry(|| {
214        let mut req = AGENT
215            .get(&url)
216            .set("Accept", "application/vnd.github+json")
217            .set("User-Agent", "mcp-methods");
218
219        if let Some(token) = auth_token() {
220            req = req.set("Authorization", &format!("Bearer {}", token));
221        }
222        req.call()
223    });
224
225    match result {
226        Ok(resp) => resp
227            .into_json::<Value>()
228            .map_err(|e| format!("JSON parse error: {}", e)),
229        Err(ureq::Error::Status(404, _)) => Err(format!("Not found: {}", endpoint)),
230        Err(ureq::Error::Status(403, resp)) => {
231            let body = resp.into_string().unwrap_or_default();
232            if body.to_lowercase().contains("rate limit") {
233                Err(
234                    "GitHub API rate limit exceeded. Set GITHUB_TOKEN or GH_TOKEN env var for higher limits."
235                        .into(),
236                )
237            } else {
238                Err(format!("GitHub API forbidden: {}", body))
239            }
240        }
241        Err(ureq::Error::Status(code, resp)) => {
242            let body = resp.into_string().unwrap_or_default();
243            Err(format!("GitHub API error ({}): {}", code, body))
244        }
245        Err(e) => Err(format!("GitHub API error: {}", e)),
246    }
247}
248
249fn gh_graphql(query: &str, variables: Value) -> Result<Value, String> {
250    let token = auth_token().ok_or(
251        "GitHub token required for Discussions (GraphQL API). \
252         Set GITHUB_TOKEN or GH_TOKEN.",
253    )?;
254
255    let body = json!({
256        "query": query,
257        "variables": variables,
258    });
259
260    let resp = AGENT
261        .post("https://api.github.com/graphql")
262        .set("Authorization", &format!("Bearer {}", token))
263        .set("User-Agent", "mcp-methods")
264        .send_json(&body)
265        .map_err(|e| match e {
266            ureq::Error::Status(401, _) => {
267                "GitHub token is invalid or expired. Check GITHUB_TOKEN / GH_TOKEN.".to_string()
268            }
269            ureq::Error::Status(code, resp) => {
270                let body = resp.into_string().unwrap_or_default();
271                format!("GitHub GraphQL error ({}): {}", code, body)
272            }
273            other => format!("GitHub GraphQL error: {}", other),
274        })?;
275
276    let result: Value = resp
277        .into_json()
278        .map_err(|e| format!("GraphQL JSON parse error: {}", e))?;
279
280    // GraphQL returns errors in {"errors": [...]} even on HTTP 200
281    if let Some(errors) = result.get("errors").and_then(|v| v.as_array()) {
282        if let Some(first) = errors.first() {
283            let msg = first
284                .get("message")
285                .and_then(|m| m.as_str())
286                .unwrap_or("Unknown GraphQL error");
287            return Err(format!("GitHub GraphQL error: {}", msg));
288        }
289    }
290
291    result
292        .get("data")
293        .cloned()
294        .ok_or_else(|| "GitHub GraphQL: no 'data' in response".to_string())
295}
296
297fn parse_link_rel(link: &str, rel: &str) -> Option<String> {
298    let tag = format!("rel=\"{}\"", rel);
299    for part in link.split(',') {
300        if part.contains(&tag) {
301            let start = part.find('<')? + 1;
302            let end = part.find('>')?;
303            return Some(part[start..end].to_string());
304        }
305    }
306    None
307}
308
309fn parse_link_next(link: &str) -> Option<String> {
310    parse_link_rel(link, "next")
311}
312
313/// Extract the last page number from a Link header URL (?page=N or &page=N).
314fn parse_last_page(link: &str) -> Option<usize> {
315    let url = parse_link_rel(link, "last")?;
316    // Find page=N in query string
317    url.split('?').nth(1)?.split('&').find_map(|param| {
318        let (k, v) = param.split_once('=')?;
319        if k == "page" {
320            v.parse().ok()
321        } else {
322            None
323        }
324    })
325}
326
327pub(crate) fn gh_get_paginated(endpoint: &str) -> Result<Vec<Value>, String> {
328    gh_get_paginated_bookends(endpoint, 0, 0)
329}
330
331/// Fetch a single page by URL (used for tail pages).
332// Closure returns ureq's large `Result` at the `.call()` boundary.
333#[allow(clippy::result_large_err)]
334fn gh_get_page(url: &str) -> Result<Vec<Value>, String> {
335    let resp = call_with_retry(|| {
336        let mut req = AGENT
337            .get(url)
338            .set("Accept", "application/vnd.github+json")
339            .set("User-Agent", "mcp-methods");
340        if let Some(token) = auth_token() {
341            req = req.set("Authorization", &format!("Bearer {}", token));
342        }
343        req.call()
344    })
345    .map_err(|e| format!("GitHub API error: {}", e))?;
346    let items: Value = resp
347        .into_json()
348        .map_err(|e| format!("JSON parse error: {}", e))?;
349    match items {
350        Value::Array(arr) => Ok(arr),
351        _ => Ok(vec![]),
352    }
353}
354
355/// Fetch paginated results: first `head` pages + last `tail` pages.
356/// If head=0 and tail=0, fetch all pages (unlimited).
357/// When total pages <= head+tail, all pages are fetched (no gap).
358// Closure returns ureq's large `Result` at the `.call()` boundary.
359#[allow(clippy::result_large_err)]
360fn gh_get_paginated_bookends(
361    endpoint: &str,
362    head: usize,
363    tail: usize,
364) -> Result<Vec<Value>, String> {
365    let mut url = format!("{}/{}", GITHUB_API, endpoint);
366    let mut all_items: Vec<Value> = Vec::new();
367    let mut pages_fetched: usize = 0;
368    let unlimited = head == 0 && tail == 0;
369    let max_head = if unlimited { usize::MAX } else { head };
370    let mut last_page: Option<usize> = None;
371    let mut skipped = false;
372
373    loop {
374        let resp = match call_with_retry(|| {
375            let mut req = AGENT
376                .get(&url)
377                .set("Accept", "application/vnd.github+json")
378                .set("User-Agent", "mcp-methods");
379
380            if let Some(token) = auth_token() {
381                req = req.set("Authorization", &format!("Bearer {}", token));
382            }
383            req.call()
384        }) {
385            Ok(r) => r,
386            Err(ureq::Error::Status(403, resp)) => {
387                let body = resp.into_string().unwrap_or_default();
388                if body.to_lowercase().contains("rate limit") {
389                    return Err(
390                        "GitHub API rate limit exceeded. Set GITHUB_TOKEN or GH_TOKEN env var for higher limits."
391                            .into(),
392                    );
393                }
394                return Err(format!("GitHub API forbidden: {}", body));
395            }
396            Err(e) => return Err(format!("GitHub API error: {}", e)),
397        };
398
399        let link_header: Option<String> = resp.header("link").map(String::from);
400        let items: Value = resp
401            .into_json()
402            .map_err(|e| format!("JSON parse error: {}", e))?;
403
404        if let Value::Array(arr) = items {
405            all_items.extend(arr);
406        }
407
408        pages_fetched += 1;
409
410        // On first page, discover total page count
411        if pages_fetched == 1 && last_page.is_none() {
412            last_page = link_header.as_deref().and_then(parse_last_page);
413        }
414
415        if pages_fetched >= max_head {
416            break;
417        }
418
419        match link_header.as_deref().and_then(parse_link_next) {
420            Some(u) => url = u,
421            None => break,
422        }
423    }
424
425    // Fetch tail pages if there's a gap
426    if !unlimited && tail > 0 {
427        if let Some(total) = last_page {
428            let tail_start = (head + 1).max(total.saturating_sub(tail) + 1);
429            if tail_start <= total {
430                skipped = tail_start > head + 1;
431                // Build URLs for tail pages
432                let base = format!("{}/{}", GITHUB_API, endpoint);
433                let sep = if base.contains('?') { '&' } else { '?' };
434                for page_num in tail_start..=total {
435                    let page_url = format!("{}{}page={}", base, sep, page_num);
436                    if let Ok(items) = gh_get_page(&page_url) {
437                        all_items.extend(items);
438                    }
439                }
440            }
441        }
442    }
443
444    if skipped {
445        // Insert a marker so callers know comments were skipped
446        all_items.push(json!({"_skipped_middle": true}));
447    }
448
449    Ok(all_items)
450}
451
452// ---------------------------------------------------------------------------
453// Discussion assembly helpers
454// ---------------------------------------------------------------------------
455
456fn json_str(val: &Value, key: &str) -> String {
457    val.get(key)
458        .and_then(|v| v.as_str())
459        .unwrap_or("")
460        .to_string()
461}
462
463fn json_author(val: &Value) -> String {
464    val.get("user")
465        .and_then(|u| u.get("login"))
466        .and_then(|v| v.as_str())
467        .unwrap_or("(deleted)")
468        .to_string()
469}
470
471fn json_body(val: &Value) -> Value {
472    match val.get("body").and_then(|v| v.as_str()) {
473        Some(s) => {
474            let trimmed = s.trim();
475            if trimmed.is_empty() {
476                Value::Null
477            } else {
478                Value::String(trimmed.to_string())
479            }
480        }
481        None => Value::Null,
482    }
483}
484
485fn parse_timeline(timeline: &[Value], repo: &str) -> Vec<Value> {
486    let mut referenced_by = Vec::new();
487    for event in timeline {
488        let etype = event.get("event").and_then(|v| v.as_str()).unwrap_or("");
489        match etype {
490            "cross-referenced" => {
491                let source = event
492                    .get("source")
493                    .and_then(|s| s.get("issue"))
494                    .unwrap_or(&Value::Null);
495                if let Some(source_number) = source.get("number").and_then(|v| v.as_u64()) {
496                    let src_url = source
497                        .get("html_url")
498                        .and_then(|v| v.as_str())
499                        .unwrap_or("");
500                    let src_repo = URL_RE
501                        .captures(src_url)
502                        .map(|c| c[1].to_string())
503                        .unwrap_or_else(|| repo.to_string());
504                    let is_pr = source.get("pull_request").is_some();
505                    referenced_by.push(json!({
506                        "event": "cross-reference",
507                        "source_type": if is_pr { "pull_request" } else { "issue" },
508                        "source_number": source_number,
509                        "source_repo": src_repo,
510                        "source_title": json_str(source, "title"),
511                        "author": event.get("actor")
512                            .and_then(|a| a.get("login"))
513                            .and_then(|v| v.as_str())
514                            .unwrap_or("(deleted)"),
515                        "created_at": json_str(event, "created_at"),
516                    }));
517                }
518            }
519            "referenced" => {
520                let sha = json_str(event, "commit_id");
521                referenced_by.push(json!({
522                    "event": "commit-reference",
523                    "commit_sha": &sha[..sha.len().min(10)],
524                    "author": event.get("actor")
525                        .and_then(|a| a.get("login"))
526                        .and_then(|v| v.as_str())
527                        .unwrap_or("(deleted)"),
528                    "created_at": json_str(event, "created_at"),
529                }));
530            }
531            _ => {}
532        }
533    }
534    referenced_by
535}
536
537fn build_inline_comment(rc: &Value, reply_map: &HashMap<u64, Vec<&Value>>) -> Value {
538    let rc_id = rc.get("id").and_then(|v| v.as_u64()).unwrap_or(0);
539    let replies: Vec<Value> = reply_map
540        .get(&rc_id)
541        .map(|rps| {
542            rps.iter()
543                .map(|rp| {
544                    json!({
545                        "author": json_author(rp),
546                        "created_at": json_str(rp, "created_at"),
547                        "body": json_body(rp),
548                    })
549                })
550                .collect()
551        })
552        .unwrap_or_default();
553
554    json!({
555        "author": json_author(rc),
556        "path": json_str(rc, "path"),
557        "line": rc.get("line").or_else(|| rc.get("original_line")).cloned().unwrap_or(Value::Null),
558        "diff_hunk": json_str(rc, "diff_hunk"),
559        "body": json_body(rc),
560        "created_at": json_str(rc, "created_at"),
561        "replies": replies,
562    })
563}
564
565fn build_reviews(reviews_raw: &[Value], review_comments_raw: &[Value]) -> Vec<Value> {
566    let mut by_review: HashMap<Option<u64>, Vec<&Value>> = HashMap::new();
567    let mut reply_map: HashMap<u64, Vec<&Value>> = HashMap::new();
568
569    for rc in review_comments_raw {
570        let rid = rc.get("pull_request_review_id").and_then(|v| v.as_u64());
571        if rc.get("in_reply_to_id").and_then(|v| v.as_u64()).is_some() {
572            let reply_to = rc["in_reply_to_id"].as_u64().unwrap();
573            reply_map.entry(reply_to).or_default().push(rc);
574        } else {
575            by_review.entry(rid).or_default().push(rc);
576        }
577    }
578
579    let mut reviews = Vec::new();
580    let mut known_review_ids = HashSet::new();
581
582    for rev in reviews_raw {
583        let rev_id = rev.get("id").and_then(|v| v.as_u64()).unwrap_or(0);
584        known_review_ids.insert(rev_id);
585
586        let rev_body = json_body(rev);
587        let rev_state = json_str(rev, "state");
588
589        if rev_state == "COMMENTED" && rev_body.is_null() && !by_review.contains_key(&Some(rev_id))
590        {
591            continue;
592        }
593
594        let inlines: Vec<Value> = by_review
595            .get(&Some(rev_id))
596            .map(|rcs| {
597                rcs.iter()
598                    .map(|rc| build_inline_comment(rc, &reply_map))
599                    .collect()
600            })
601            .unwrap_or_default();
602
603        reviews.push(json!({
604            "author": json_author(rev),
605            "author_association": json_str(rev, "author_association"),
606            "state": rev_state,
607            "submitted_at": json_str(rev, "submitted_at"),
608            "body": rev_body,
609            "inline_comments": inlines,
610        }));
611    }
612
613    // Orphan inline comments (not linked to a known review)
614    for (rid, rcs) in &by_review {
615        if let Some(id) = rid {
616            if known_review_ids.contains(id) {
617                continue;
618            }
619        }
620        for rc in rcs {
621            reviews.push(json!({
622                "author": json_author(rc),
623                "author_association": json_str(rc, "author_association"),
624                "state": "COMMENTED",
625                "submitted_at": json_str(rc, "created_at"),
626                "body": Value::Null,
627                "inline_comments": vec![build_inline_comment(rc, &reply_map)],
628            }));
629        }
630    }
631
632    reviews
633}
634
635// ---------------------------------------------------------------------------
636// GitHub Discussions (GraphQL)
637// ---------------------------------------------------------------------------
638
639const DISCUSSION_QUERY: &str = r#"query($owner: String!, $repo: String!, $number: Int!) {
640  repository(owner: $owner, name: $repo) {
641    discussion(number: $number) {
642      number
643      title
644      body
645      author { login }
646      authorAssociation
647      createdAt
648      updatedAt
649      url
650      closed
651      locked
652      answer { id }
653      labels(first: 20) { nodes { name } }
654      category { name }
655      comments(first: 100) {
656        totalCount
657        nodes {
658          author { login }
659          authorAssociation
660          createdAt
661          body
662          isAnswer
663          replies(first: 100) {
664            nodes {
665              author { login }
666              authorAssociation
667              createdAt
668              body
669            }
670          }
671        }
672      }
673    }
674  }
675}"#;
676
677fn gql_author(val: &Value) -> String {
678    val.get("author")
679        .and_then(|u| u.get("login"))
680        .and_then(|v| v.as_str())
681        .unwrap_or("(deleted)")
682        .to_string()
683}
684
685fn gql_body(val: &Value) -> Value {
686    match val.get("body").and_then(|v| v.as_str()) {
687        Some(s) => {
688            let trimmed = s.trim();
689            if trimmed.is_empty() {
690                Value::Null
691            } else {
692                Value::String(trimmed.to_string())
693            }
694        }
695        None => Value::Null,
696    }
697}
698
699fn fetch_discussion_graphql(repo: &str, number: u64) -> Result<Value, String> {
700    let (owner, name) = repo
701        .split_once('/')
702        .ok_or_else(|| "Invalid repo format for GraphQL".to_string())?;
703
704    let data = gh_graphql(
705        DISCUSSION_QUERY,
706        json!({"owner": owner, "repo": name, "number": number as i64}),
707    )?;
708
709    let disc = data
710        .get("repository")
711        .and_then(|r| r.get("discussion"))
712        .ok_or_else(|| format!("Discussion #{} not found in {}", number, repo))?;
713
714    if disc.is_null() {
715        return Err(format!("Discussion #{} not found in {}", number, repo));
716    }
717
718    let closed = disc
719        .get("closed")
720        .and_then(|v| v.as_bool())
721        .unwrap_or(false);
722    let has_answer = disc.get("answer").map(|v| !v.is_null()).unwrap_or(false);
723
724    let labels: Vec<Value> = disc
725        .get("labels")
726        .and_then(|l| l.get("nodes"))
727        .and_then(|v| v.as_array())
728        .map(|arr| {
729            arr.iter()
730                .filter_map(|l| {
731                    l.get("name")
732                        .and_then(|n| n.as_str())
733                        .map(|s| Value::String(s.to_string()))
734                })
735                .collect()
736        })
737        .unwrap_or_default();
738
739    let category = disc
740        .get("category")
741        .and_then(|c| c.get("name"))
742        .and_then(|v| v.as_str())
743        .unwrap_or("")
744        .to_string();
745
746    let comment_count = disc
747        .get("comments")
748        .and_then(|c| c.get("totalCount"))
749        .and_then(|v| v.as_u64())
750        .unwrap_or(0);
751
752    // Build threaded comments
753    let comments: Vec<Value> = disc
754        .get("comments")
755        .and_then(|c| c.get("nodes"))
756        .and_then(|v| v.as_array())
757        .map(|nodes| {
758            nodes
759                .iter()
760                .map(|c| {
761                    let replies: Vec<Value> = c
762                        .get("replies")
763                        .and_then(|r| r.get("nodes"))
764                        .and_then(|v| v.as_array())
765                        .map(|rps| {
766                            rps.iter()
767                                .map(|rp| {
768                                    json!({
769                                        "author": gql_author(rp),
770                                        "author_association": rp.get("authorAssociation")
771                                            .and_then(|v| v.as_str()).unwrap_or(""),
772                                        "created_at": rp.get("createdAt")
773                                            .and_then(|v| v.as_str()).unwrap_or(""),
774                                        "body": gql_body(rp),
775                                    })
776                                })
777                                .collect()
778                        })
779                        .unwrap_or_default();
780
781                    let is_answer = c.get("isAnswer").and_then(|v| v.as_bool()).unwrap_or(false);
782
783                    let mut comment = json!({
784                        "author": gql_author(c),
785                        "author_association": c.get("authorAssociation")
786                            .and_then(|v| v.as_str()).unwrap_or(""),
787                        "created_at": c.get("createdAt")
788                            .and_then(|v| v.as_str()).unwrap_or(""),
789                        "body": gql_body(c),
790                    });
791
792                    if is_answer {
793                        comment["is_answer"] = Value::Bool(true);
794                    }
795                    if !replies.is_empty() {
796                        comment["replies"] = Value::Array(replies);
797                    }
798
799                    comment
800                })
801                .collect()
802        })
803        .unwrap_or_default();
804
805    let mut result = json!({
806        "type": "discussion",
807        "number": number,
808        "repo": repo,
809        "title": disc.get("title").and_then(|v| v.as_str()).unwrap_or(""),
810        "state": if closed { "closed" } else { "open" },
811        "author": gql_author(disc),
812        "author_association": disc.get("authorAssociation")
813            .and_then(|v| v.as_str()).unwrap_or(""),
814        "created_at": disc.get("createdAt").and_then(|v| v.as_str()).unwrap_or(""),
815        "updated_at": disc.get("updatedAt").and_then(|v| v.as_str()).unwrap_or(""),
816        "url": disc.get("url").and_then(|v| v.as_str()).unwrap_or(""),
817        "labels": labels,
818        "body": gql_body(disc),
819        "comment_count": comment_count,
820        "comments": comments,
821    });
822
823    if !category.is_empty() {
824        result["category"] = Value::String(category);
825    }
826    if has_answer {
827        result["answered"] = Value::Bool(true);
828    }
829
830    Ok(result)
831}
832
833/// Fetch a GitHub Discussion via GraphQL, collect refs, compact.
834/// Parallel to `fetch_issue_internal` but for Discussions.
835fn fetch_gh_discussion_internal(
836    repo: &str,
837    number: u64,
838) -> Result<(String, Option<String>), String> {
839    let mut parent = fetch_discussion_graphql(repo, number)?;
840
841    // Collect GitHub refs
842    let seen: HashSet<(String, u64)> = [(repo.to_string(), number)].into();
843    let all_refs = collect_refs_from_discussion(&parent, repo);
844    let mut refs: Vec<(String, u64)> = all_refs.difference(&seen).cloned().collect();
845    refs.sort();
846    refs.truncate(MAX_RELATED);
847
848    if !refs.is_empty() {
849        let ref_list: Vec<Value> = refs
850            .iter()
851            .map(|(r, n)| json!({"repo": r, "number": n}))
852            .collect();
853        parent["related_refs"] = Value::Array(ref_list);
854    }
855
856    // Compact
857    let parent_json = serde_json::to_string(&parent).map_err(|e| format!("JSON error: {}", e))?;
858    let cache_json = serde_json::to_string(&json!({"_n": 0})).unwrap();
859    let (compacted, cache_out) =
860        compact::compact_discussion(&parent_json, Some(&cache_json), None, None)
861            .map_err(|e| format!("Compaction error: {}", e))?;
862
863    Ok((compacted, cache_out))
864}
865
866// ---------------------------------------------------------------------------
867// Issue/PR fetching (parallel HTTP, no GIL)
868// ---------------------------------------------------------------------------
869
870fn fetch_single_discussion(
871    repo: &str,
872    number: u64,
873    include_files: bool,
874    include_timeline: bool,
875) -> Result<Value, String> {
876    // First request must be sequential — need to know if it's a PR
877    let issue = gh_get(&format!("repos/{}/issues/{}", repo, number))?;
878    let is_pr = issue.get("pull_request").is_some();
879
880    let mut result = json!({
881        "type": if is_pr { "pull_request" } else { "issue" },
882        "number": number,
883        "repo": repo,
884        "title": json_str(&issue, "title"),
885        "state": json_str(&issue, "state"),
886        "author": json_author(&issue),
887        "author_association": json_str(&issue, "author_association"),
888        "created_at": json_str(&issue, "created_at"),
889        "updated_at": json_str(&issue, "updated_at"),
890        "url": json_str(&issue, "html_url"),
891        "labels": issue.get("labels")
892            .and_then(|v| v.as_array())
893            .map(|arr| arr.iter()
894                .filter_map(|l| l.get("name").and_then(|n| n.as_str()).map(|s| Value::String(s.to_string())))
895                .collect::<Vec<_>>())
896            .unwrap_or_default(),
897        "body": json_body(&issue),
898        "comment_count": issue.get("comments").and_then(|v| v.as_u64()).unwrap_or(0),
899    });
900
901    // Fire all remaining requests in parallel
902    std::thread::scope(|s| {
903        let comments_h = s.spawn(|| {
904            gh_get_paginated_bookends(
905                &format!("repos/{}/issues/{}/comments", repo, number),
906                COMMENT_HEAD_PAGES,
907                COMMENT_TAIL_PAGES,
908            )
909        });
910        let timeline_h = if include_timeline {
911            Some(s.spawn(|| {
912                gh_get_paginated_bookends(
913                    &format!("repos/{}/issues/{}/timeline", repo, number),
914                    TIMELINE_HEAD_PAGES,
915                    TIMELINE_TAIL_PAGES,
916                )
917            }))
918        } else {
919            None
920        };
921        let pr_h = if is_pr {
922            Some(s.spawn(|| gh_get(&format!("repos/{}/pulls/{}", repo, number))))
923        } else {
924            None
925        };
926        let reviews_h = if is_pr {
927            Some(s.spawn(|| gh_get_paginated(&format!("repos/{}/pulls/{}/reviews", repo, number))))
928        } else {
929            None
930        };
931        let review_comments_h = if is_pr {
932            Some(s.spawn(|| gh_get_paginated(&format!("repos/{}/pulls/{}/comments", repo, number))))
933        } else {
934            None
935        };
936        let files_h = if is_pr && include_files {
937            Some(s.spawn(|| gh_get_paginated(&format!("repos/{}/pulls/{}/files", repo, number))))
938        } else {
939            None
940        };
941
942        // Collect: comments
943        let comments = comments_h.join().unwrap().unwrap_or_default();
944        result["comments"] = Value::Array(
945            comments
946                .iter()
947                .map(|c| {
948                    if c.get("_skipped_middle").is_some() {
949                        return json!({
950                            "author": "[system]",
951                            "body": "--- older comments omitted (middle pages skipped) ---",
952                        });
953                    }
954                    json!({
955                        "author": json_author(c),
956                        "author_association": json_str(c, "author_association"),
957                        "created_at": json_str(c, "created_at"),
958                        "body": json_body(c),
959                    })
960                })
961                .collect(),
962        );
963
964        // Collect: timeline
965        if let Some(handle) = timeline_h {
966            if let Ok(timeline) = handle.join().unwrap() {
967                let referenced_by = parse_timeline(&timeline, repo);
968                if !referenced_by.is_empty() {
969                    result["referenced_by"] = Value::Array(referenced_by);
970                }
971            }
972        }
973
974        // Collect: PR data
975        if is_pr {
976            if let Some(handle) = pr_h {
977                if let Ok(pr_data) = handle.join().unwrap() {
978                    let merged = pr_data
979                        .get("merged")
980                        .and_then(|v| v.as_bool())
981                        .unwrap_or(false);
982                    result["merged"] = Value::Bool(merged);
983                    if merged {
984                        result["merged_by"] = pr_data
985                            .get("merged_by")
986                            .and_then(|u| u.get("login"))
987                            .cloned()
988                            .unwrap_or(Value::Null);
989                        result["merged_at"] =
990                            pr_data.get("merged_at").cloned().unwrap_or(Value::Null);
991                    }
992                    result["base"] = Value::String(
993                        pr_data
994                            .get("base")
995                            .and_then(|b| b.get("ref"))
996                            .and_then(|v| v.as_str())
997                            .unwrap_or("")
998                            .to_string(),
999                    );
1000                    result["head"] = Value::String(
1001                        pr_data
1002                            .get("head")
1003                            .and_then(|h| h.get("label"))
1004                            .and_then(|v| v.as_str())
1005                            .unwrap_or("")
1006                            .to_string(),
1007                    );
1008                    result["additions"] =
1009                        pr_data.get("additions").cloned().unwrap_or(Value::from(0));
1010                    result["deletions"] =
1011                        pr_data.get("deletions").cloned().unwrap_or(Value::from(0));
1012                    result["changed_files"] = pr_data
1013                        .get("changed_files")
1014                        .cloned()
1015                        .unwrap_or(Value::from(0));
1016                }
1017            }
1018
1019            let reviews = reviews_h
1020                .and_then(|h| h.join().ok())
1021                .and_then(|r| r.ok())
1022                .unwrap_or_default();
1023            let review_comments = review_comments_h
1024                .and_then(|h| h.join().ok())
1025                .and_then(|r| r.ok())
1026                .unwrap_or_default();
1027            result["reviews"] = Value::Array(build_reviews(&reviews, &review_comments));
1028
1029            if let Some(handle) = files_h {
1030                let files = handle.join().unwrap().unwrap_or_default();
1031                result["files"] = Value::Array(
1032                    files
1033                        .iter()
1034                        .map(|f| {
1035                            json!({
1036                                "filename": json_str(f, "filename"),
1037                                "status": json_str(f, "status"),
1038                                "additions": f.get("additions").and_then(|v| v.as_u64()).unwrap_or(0),
1039                                "deletions": f.get("deletions").and_then(|v| v.as_u64()).unwrap_or(0),
1040                                "patch": f.get("patch").cloned().unwrap_or(Value::Null),
1041                            })
1042                        })
1043                        .collect(),
1044                );
1045            }
1046        }
1047    });
1048
1049    Ok(result)
1050}
1051
1052// ---------------------------------------------------------------------------
1053// Ref collection from discussion
1054// ---------------------------------------------------------------------------
1055
1056fn iter_discussion_texts(result: &Value) -> Vec<&str> {
1057    let mut texts = Vec::new();
1058    if let Some(body) = result.get("body").and_then(|v| v.as_str()) {
1059        if !body.is_empty() {
1060            texts.push(body);
1061        }
1062    }
1063    for field in &["comments", "reviews"] {
1064        if let Some(arr) = result.get(*field).and_then(|v| v.as_array()) {
1065            for item in arr {
1066                if let Some(body) = item.get("body").and_then(|v| v.as_str()) {
1067                    if !body.is_empty() {
1068                        texts.push(body);
1069                    }
1070                }
1071                // Direct replies on comments (Discussions — threaded)
1072                if let Some(replies) = item.get("replies").and_then(|v| v.as_array()) {
1073                    for rp in replies {
1074                        if let Some(body) = rp.get("body").and_then(|v| v.as_str()) {
1075                            if !body.is_empty() {
1076                                texts.push(body);
1077                            }
1078                        }
1079                    }
1080                }
1081                // Inline comments (reviews only)
1082                if let Some(inlines) = item.get("inline_comments").and_then(|v| v.as_array()) {
1083                    for ic in inlines {
1084                        if let Some(body) = ic.get("body").and_then(|v| v.as_str()) {
1085                            if !body.is_empty() {
1086                                texts.push(body);
1087                            }
1088                        }
1089                        if let Some(replies) = ic.get("replies").and_then(|v| v.as_array()) {
1090                            for rp in replies {
1091                                if let Some(body) = rp.get("body").and_then(|v| v.as_str()) {
1092                                    if !body.is_empty() {
1093                                        texts.push(body);
1094                                    }
1095                                }
1096                            }
1097                        }
1098                    }
1099                }
1100            }
1101        }
1102    }
1103    texts
1104}
1105
1106fn collect_refs_from_discussion(result: &Value, default_repo: &str) -> HashSet<(String, u64)> {
1107    let mut refs = HashSet::new();
1108    for text in iter_discussion_texts(result) {
1109        for (repo, num) in git_refs::extract_github_refs(text, default_repo) {
1110            refs.insert((repo, num));
1111        }
1112    }
1113    if let Some(referenced_by) = result.get("referenced_by").and_then(|v| v.as_array()) {
1114        for ref_item in referenced_by {
1115            if ref_item.get("event").and_then(|v| v.as_str()) == Some("cross-reference") {
1116                if let Some(source_number) = ref_item.get("source_number").and_then(|v| v.as_u64())
1117                {
1118                    let source_repo = ref_item
1119                        .get("source_repo")
1120                        .and_then(|v| v.as_str())
1121                        .unwrap_or(default_repo)
1122                        .to_string();
1123                    refs.insert((source_repo, source_number));
1124                }
1125            }
1126        }
1127    }
1128    refs
1129}
1130
1131// ---------------------------------------------------------------------------
1132// Public internal API (called from cache.rs with GIL released)
1133// ---------------------------------------------------------------------------
1134
1135/// Fetch, assemble, compact, and return (compacted_json, cache_entries_json).
1136///
1137/// This function does all network I/O and CPU work. Designed to run with the
1138/// GIL released via `py.allow_threads()`.
1139pub fn fetch_issue_internal(repo: &str, number: u64) -> Result<(String, Option<String>), String> {
1140    if !has_git_token() {
1141        return Err(
1142            "No GitHub token found. A token is required for fetching issues/PRs \
1143             (cross-references, higher rate limits).\n\n\
1144             Set the GITHUB_TOKEN or GH_TOKEN environment variable, or use \
1145             load_env() to load it from a .env file.\n\n\
1146             The token needs no special scopes — a classic PAT with default (no) \
1147             permissions works for public repos."
1148                .into(),
1149        );
1150    }
1151
1152    // Fetch parent: try REST (issue/PR) first; fall back to GraphQL (Discussion) on 404
1153    let mut parent = match fetch_single_discussion(repo, number, true, true) {
1154        Ok(val) => val,
1155        Err(e) if e.starts_with("Not found:") => {
1156            // REST 404 — might be a Discussion. Try GraphQL.
1157            return match fetch_gh_discussion_internal(repo, number) {
1158                Ok(result) => Ok(result),
1159                Err(_) => Err(format!(
1160                    "#{} not found in {} (checked Issues, PRs, and Discussions).",
1161                    number, repo
1162                )),
1163            };
1164        }
1165        Err(e) => return Err(e),
1166    };
1167
1168    // Collect GitHub refs
1169    let seen: HashSet<(String, u64)> = [(repo.to_string(), number)].into();
1170    let all_refs = collect_refs_from_discussion(&parent, repo);
1171    let mut refs: Vec<(String, u64)> = all_refs.difference(&seen).cloned().collect();
1172    refs.sort();
1173    refs.truncate(MAX_RELATED);
1174
1175    if !refs.is_empty() {
1176        // List refs for the agent to dive into on demand — no extra fetches
1177        let ref_list: Vec<Value> = refs
1178            .iter()
1179            .map(|(r, n)| json!({"repo": r, "number": n}))
1180            .collect();
1181        parent["related_refs"] = Value::Array(ref_list);
1182    }
1183
1184    // Compact
1185    let parent_json = serde_json::to_string(&parent).map_err(|e| format!("JSON error: {}", e))?;
1186    let cache_json = serde_json::to_string(&json!({"_n": 0})).unwrap();
1187    let (compacted, cache_out) =
1188        compact::compact_discussion(&parent_json, Some(&cache_json), None, None)
1189            .map_err(|e| format!("Compaction error: {}", e))?;
1190
1191    Ok((compacted, cache_out))
1192}
1193
1194// ---------------------------------------------------------------------------
1195// git_api — generic GitHub REST API access (no GIL needed)
1196// ---------------------------------------------------------------------------
1197
1198/// Build the GitHub REST API URL for a `git_api` call.
1199///
1200/// Paths naming a top-level resource (`repos/...`, `search/...`, …) pass
1201/// through unchanged; anything else is treated as relative to `repo` and
1202/// wrapped in `/repos/<repo>/`. A single leading slash is stripped first,
1203/// so `/repos/...` and `repos/...` are equivalent — an agent writing the
1204/// idiomatic absolute form from the GitHub REST docs gets the same URL as
1205/// the relative form rather than a doubled `/repos/` prefix.
1206fn build_git_api_url(repo: &str, path: &str) -> String {
1207    // `/repos/...` == `repos/...` — normalise before the prefix check.
1208    let path = path.strip_prefix('/').unwrap_or(path);
1209
1210    let top_level = [
1211        "search/",
1212        "users/",
1213        "orgs/",
1214        "gists/",
1215        "rate_limit",
1216        "repos/",
1217    ];
1218    if top_level.iter().any(|p| path.starts_with(p)) {
1219        format!("{}/{}", GITHUB_API, path)
1220    } else {
1221        format!("{}/repos/{}/{}", GITHUB_API, repo, path)
1222    }
1223}
1224
1225pub fn git_api_internal(repo: &str, path: &str, truncate_at: usize) -> String {
1226    if let Some(err) = git_refs::validate_repo(repo) {
1227        return err;
1228    }
1229
1230    let url = build_git_api_url(repo, path);
1231
1232    match gh_get(&url) {
1233        Ok(data) => {
1234            let text = serde_json::to_string_pretty(&data).unwrap_or_default();
1235            if text.len() > truncate_at {
1236                format!(
1237                    "{}\n\n... (truncated, refine your query)",
1238                    &text[..compact::safe_byte_index(&text, truncate_at)]
1239                )
1240            } else {
1241                text
1242            }
1243        }
1244        Err(e) => e,
1245    }
1246}
1247
1248// ---------------------------------------------------------------------------
1249// PyO3 wrappers — only compiled with the `python` feature.
1250// Pure-Rust callers use the `*_internal` / `*_rust` companions directly.
1251// ---------------------------------------------------------------------------
1252
1253/// Pure-Rust dispatcher for the github_issues tool.
1254///
1255/// Returns a user-facing string for all logical conditions (invalid repo,
1256/// fetch failure, etc.). Callers that want structured errors should
1257/// invoke the `_internal` functions directly.
1258#[allow(clippy::too_many_arguments)]
1259pub fn github_issues_rust(
1260    repo: Option<&str>,
1261    number: Option<u64>,
1262    query: Option<&str>,
1263    kind: &str,
1264    state: &str,
1265    sort: Option<&str>,
1266    limit: usize,
1267    labels: Option<&str>,
1268) -> String {
1269    let repo_str = match repo {
1270        Some(r) => r.to_string(),
1271        None => match detect_git_repo(".") {
1272            Some(r) => r,
1273            None => {
1274                return "No repo specified and could not auto-detect from git remote.".to_string()
1275            }
1276        },
1277    };
1278    if let Some(err) = git_refs::validate_repo(&repo_str) {
1279        return err;
1280    }
1281
1282    match (number, query) {
1283        (Some(num), _) => match fetch_issue_internal(&repo_str, num) {
1284            Ok((text, _cache)) => text,
1285            Err(e) => e,
1286        },
1287        (None, Some(q)) => search_issues_dispatch(&repo_str, q, kind, state, sort, limit, labels),
1288        (None, None) => list_issues_internal(
1289            &repo_str,
1290            kind,
1291            state,
1292            sort.unwrap_or("created"),
1293            limit,
1294            labels,
1295        ),
1296    }
1297}
1298
1299// ---------------------------------------------------------------------------
1300// Search
1301// ---------------------------------------------------------------------------
1302
1303/// Build GitHub search qualifier string from structured parameters.
1304fn build_search_qualifiers(repo: &str, kind: &str, state: &str, labels: Option<&str>) -> String {
1305    let mut q = format!(" repo:{}", repo);
1306    match kind {
1307        "issue" => q.push_str(" type:issue"),
1308        "pr" => q.push_str(" type:pr"),
1309        _ => {} // "all" / "discussion" — no type qualifier
1310    }
1311    match state {
1312        "open" => q.push_str(" state:open"),
1313        "closed" => q.push_str(" state:closed"),
1314        _ => {} // "all"
1315    }
1316    if let Some(lbls) = labels {
1317        for label in lbls.split(',') {
1318            let label = label.trim();
1319            if !label.is_empty() {
1320                if label.contains(' ') {
1321                    q.push_str(&format!(" label:\"{}\"", label));
1322                } else {
1323                    q.push_str(&format!(" label:{}", label));
1324                }
1325            }
1326        }
1327    }
1328    q
1329}
1330
1331/// SEARCH mode: issues + PRs via REST search/issues API.
1332// Closure returns ureq's large `Result` at the `.call()` boundary.
1333#[allow(clippy::result_large_err)]
1334fn search_issues_internal(
1335    repo: &str,
1336    user_query: &str,
1337    kind: &str,
1338    state: &str,
1339    sort: Option<&str>,
1340    limit: usize,
1341    labels: Option<&str>,
1342) -> String {
1343    let q = format!(
1344        "{}{}",
1345        user_query,
1346        build_search_qualifiers(repo, kind, state, labels)
1347    );
1348    let per_page = limit.min(100);
1349
1350    let result = call_with_retry(|| {
1351        let mut req = AGENT
1352            .get(&format!("{}/search/issues", GITHUB_API))
1353            .set("Accept", "application/vnd.github+json")
1354            .set("User-Agent", "mcp-methods")
1355            .query("q", &q)
1356            .query("per_page", &per_page.to_string());
1357
1358        if let Some(s) = sort {
1359            req = req.query("sort", s);
1360        }
1361        // When sort is None, GitHub defaults to "best match" (relevance)
1362
1363        if let Some(token) = auth_token() {
1364            req = req.set("Authorization", &format!("Bearer {}", token));
1365        }
1366        req.call()
1367    });
1368
1369    match result {
1370        Ok(resp) => {
1371            let data: Value = match resp.into_json() {
1372                Ok(v) => v,
1373                Err(e) => return format!("JSON parse error: {}", e),
1374            };
1375            format_search_results(repo, user_query, &data)
1376        }
1377        Err(ureq::Error::Status(422, resp)) => {
1378            let body = resp.into_string().unwrap_or_default();
1379            format!("GitHub search validation error: {}", body)
1380        }
1381        Err(ureq::Error::Status(403, resp)) => {
1382            let body = resp.into_string().unwrap_or_default();
1383            if body.to_lowercase().contains("rate limit") {
1384                "GitHub API rate limit exceeded. Set GITHUB_TOKEN or GH_TOKEN for higher limits."
1385                    .to_string()
1386            } else {
1387                format!("GitHub API forbidden: {}", body)
1388            }
1389        }
1390        Err(e) => format!("GitHub search error: {}", e),
1391    }
1392}
1393
1394/// SEARCH mode: Discussions via GraphQL search(type: DISCUSSION).
1395fn search_discussions_graphql(
1396    repo: &str,
1397    user_query: &str,
1398    state: &str,
1399    sort: Option<&str>,
1400    limit: usize,
1401    labels: Option<&str>,
1402) -> String {
1403    let qualifiers = build_search_qualifiers(repo, "discussion", state, labels);
1404    let q = format!("{}{}", user_query, qualifiers);
1405    let per_page = limit.min(100);
1406
1407    // GraphQL search doesn't support sort directly in the query — the search
1408    // endpoint always returns by relevance. sort is ignored for Discussions.
1409    let _ = sort;
1410
1411    let query = r#"query($q: String!, $first: Int!) {
1412  search(type: DISCUSSION, query: $q, first: $first) {
1413    discussionCount
1414    nodes {
1415      ... on Discussion {
1416        number
1417        title
1418        author { login }
1419        createdAt
1420        closed
1421        comments { totalCount }
1422        category { name }
1423        labels(first: 5) { nodes { name } }
1424        answer { id }
1425      }
1426    }
1427  }
1428}"#;
1429
1430    let vars = json!({"q": q, "first": per_page as i64});
1431
1432    let data = match gh_graphql(query, vars) {
1433        Ok(d) => d,
1434        Err(e) => return e,
1435    };
1436
1437    let total = data
1438        .get("search")
1439        .and_then(|s| s.get("discussionCount"))
1440        .and_then(|v| v.as_u64())
1441        .unwrap_or(0);
1442    let nodes = match data
1443        .get("search")
1444        .and_then(|s| s.get("nodes"))
1445        .and_then(|v| v.as_array())
1446    {
1447        Some(n) if !n.is_empty() => n,
1448        _ => return format!("No discussion results for \"{}\" in {}.", user_query, repo),
1449    };
1450
1451    let mut out = format!(
1452        "{} discussion{} (of {}) for \"{}\" in {}:\n",
1453        nodes.len(),
1454        if nodes.len() == 1 { "" } else { "s" },
1455        total,
1456        user_query,
1457        repo,
1458    );
1459
1460    for d in nodes {
1461        let number = d.get("number").and_then(|v| v.as_u64()).unwrap_or(0);
1462        if number == 0 {
1463            continue; // skip non-Discussion nodes in union result
1464        }
1465        let title = d.get("title").and_then(|v| v.as_str()).unwrap_or("");
1466        let author = gql_author(d);
1467        let date = d
1468            .get("createdAt")
1469            .and_then(|v| v.as_str())
1470            .and_then(|s| s.get(..10))
1471            .unwrap_or("");
1472        let comment_count = d
1473            .get("comments")
1474            .and_then(|c| c.get("totalCount"))
1475            .and_then(|v| v.as_u64())
1476            .unwrap_or(0);
1477        let comments = if comment_count > 0 {
1478            format!(
1479                ", {} comment{}",
1480                comment_count,
1481                if comment_count == 1 { "" } else { "s" }
1482            )
1483        } else {
1484            String::new()
1485        };
1486        let category = d
1487            .get("category")
1488            .and_then(|c| c.get("name"))
1489            .and_then(|v| v.as_str())
1490            .unwrap_or("");
1491        let cat_tag = if category.is_empty() {
1492            String::new()
1493        } else {
1494            format!(" [{}]", category)
1495        };
1496        let label_str: String = d
1497            .get("labels")
1498            .and_then(|l| l.get("nodes"))
1499            .and_then(|v| v.as_array())
1500            .map(|arr| {
1501                arr.iter()
1502                    .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
1503                    .collect::<Vec<_>>()
1504                    .join(", ")
1505            })
1506            .filter(|s| !s.is_empty())
1507            .map(|s| format!(" [{}]", s))
1508            .unwrap_or_default();
1509        let answered = if d.get("answer").map(|v| !v.is_null()).unwrap_or(false) {
1510            " [answered]"
1511        } else {
1512            ""
1513        };
1514
1515        out.push_str(&format!(
1516            "  #{}{}{}{} {} — {} ({}{})\n",
1517            number, cat_tag, label_str, answered, title, author, date, comments
1518        ));
1519    }
1520
1521    out.trim_end().to_string()
1522}
1523
1524/// Route SEARCH mode to the right backend based on `kind`.
1525pub fn search_issues_dispatch(
1526    repo: &str,
1527    query: &str,
1528    kind: &str,
1529    state: &str,
1530    sort: Option<&str>,
1531    limit: usize,
1532    labels: Option<&str>,
1533) -> String {
1534    match kind {
1535        "discussion" => search_discussions_graphql(repo, query, state, sort, limit, labels),
1536        "issue" | "pr" => search_issues_internal(repo, query, kind, state, sort, limit, labels),
1537        _ => {
1538            // kind="all": run REST for issues + PRs, and GraphQL for Discussions.
1539            // GitHub's search/issues endpoint requires a type qualifier, so we run
1540            // two separate REST searches and merge the results.
1541            let issues = search_issues_internal(repo, query, "issue", state, sort, limit, labels);
1542            let prs = search_issues_internal(repo, query, "pr", state, sort, limit, labels);
1543            let rest = match (
1544                issues.starts_with("No results"),
1545                prs.starts_with("No results"),
1546            ) {
1547                (true, true) => issues, // both empty — return the "No results" message
1548                (true, false) => prs,
1549                (false, true) => issues,
1550                (false, false) => format!("{}\n\n{}", issues, prs),
1551            };
1552            let gql = search_discussions_graphql(repo, query, state, sort, limit, labels);
1553            if gql.starts_with("No discussion") {
1554                rest
1555            } else if rest.starts_with("No results") {
1556                gql
1557            } else {
1558                format!("{}\n\n{}", rest, gql)
1559            }
1560        }
1561    }
1562}
1563
1564/// Format REST search/issues results.
1565fn format_search_results(repo: &str, user_query: &str, data: &Value) -> String {
1566    let total = data
1567        .get("total_count")
1568        .and_then(|v| v.as_u64())
1569        .unwrap_or(0);
1570    let items = match data.get("items").and_then(|v| v.as_array()) {
1571        Some(arr) if !arr.is_empty() => arr,
1572        _ => return format!("No results for \"{}\" in {}.", user_query, repo),
1573    };
1574
1575    let mut out = format!(
1576        "{} result{} (of {}) for \"{}\" in {}:\n",
1577        items.len(),
1578        if items.len() == 1 { "" } else { "s" },
1579        total,
1580        user_query,
1581        repo,
1582    );
1583
1584    for item in items {
1585        let is_pr = item.get("pull_request").is_some();
1586        if is_pr {
1587            let number = item.get("number").and_then(|v| v.as_u64()).unwrap_or(0);
1588            let title = json_str(item, "title");
1589            let author = json_author(item);
1590            let labels = format_label_tags(item);
1591            let date = format_date(item, "created_at");
1592            let comments = format_comments(item);
1593            out.push_str(&format!(
1594                "  #{}{} [PR] {} — {} ({}{})\n",
1595                number, labels, title, author, date, comments
1596            ));
1597        } else {
1598            out.push_str(&format_issue_line(item));
1599            out.push('\n');
1600        }
1601    }
1602
1603    out.trim_end().to_string()
1604}
1605
1606// ---------------------------------------------------------------------------
1607// Listing
1608// ---------------------------------------------------------------------------
1609
1610fn list_discussions_graphql(repo: &str, state: &str, sort: &str, per_page: usize) -> String {
1611    let (owner, name) = match repo.split_once('/') {
1612        Some(pair) => pair,
1613        None => return format!("Invalid repo format: {}", repo),
1614    };
1615
1616    let order_field = match sort {
1617        "updated" => "UPDATED_AT",
1618        _ => "CREATED_AT",
1619    };
1620
1621    let states: Value = match state {
1622        "open" => json!(["OPEN"]),
1623        "closed" => json!(["CLOSED"]),
1624        _ => Value::Null,
1625    };
1626
1627    // orderBy uses an enum value, so interpolate it into the query string
1628    let query = format!(
1629        r#"query($owner: String!, $repo: String!, $first: Int!, $states: [DiscussionState!]) {{
1630  repository(owner: $owner, name: $repo) {{
1631    discussions(first: $first, states: $states, orderBy: {{field: {}, direction: DESC}}) {{
1632      nodes {{
1633        number
1634        title
1635        author {{ login }}
1636        createdAt
1637        closed
1638        comments {{ totalCount }}
1639        category {{ name }}
1640        labels(first: 5) {{ nodes {{ name }} }}
1641        answer {{ id }}
1642      }}
1643    }}
1644  }}
1645}}"#,
1646        order_field
1647    );
1648
1649    let vars = json!({
1650        "owner": owner,
1651        "repo": name,
1652        "first": per_page.min(100) as i64,
1653        "states": states,
1654    });
1655
1656    let data = match gh_graphql(&query, vars) {
1657        Ok(d) => d,
1658        Err(e) => return e,
1659    };
1660
1661    let nodes = match data
1662        .get("repository")
1663        .and_then(|r| r.get("discussions"))
1664        .and_then(|d| d.get("nodes"))
1665        .and_then(|v| v.as_array())
1666    {
1667        Some(n) if !n.is_empty() => n,
1668        _ => return format!("No {} discussions in {}.", state, repo),
1669    };
1670
1671    let mut out = format!(
1672        "{} discussion{} in {} ({}):\n",
1673        nodes.len(),
1674        if nodes.len() == 1 { "" } else { "s" },
1675        repo,
1676        state
1677    );
1678
1679    for d in nodes {
1680        let number = d.get("number").and_then(|v| v.as_u64()).unwrap_or(0);
1681        let title = d.get("title").and_then(|v| v.as_str()).unwrap_or("");
1682        let author = gql_author(d);
1683        let date = d
1684            .get("createdAt")
1685            .and_then(|v| v.as_str())
1686            .and_then(|s| s.get(..10))
1687            .unwrap_or("");
1688        let comment_count = d
1689            .get("comments")
1690            .and_then(|c| c.get("totalCount"))
1691            .and_then(|v| v.as_u64())
1692            .unwrap_or(0);
1693        let comments = if comment_count > 0 {
1694            format!(
1695                ", {} comment{}",
1696                comment_count,
1697                if comment_count == 1 { "" } else { "s" }
1698            )
1699        } else {
1700            String::new()
1701        };
1702        let category = d
1703            .get("category")
1704            .and_then(|c| c.get("name"))
1705            .and_then(|v| v.as_str())
1706            .unwrap_or("");
1707        let cat_tag = if category.is_empty() {
1708            String::new()
1709        } else {
1710            format!(" [{}]", category)
1711        };
1712        let label_str: String = d
1713            .get("labels")
1714            .and_then(|l| l.get("nodes"))
1715            .and_then(|v| v.as_array())
1716            .map(|arr| {
1717                arr.iter()
1718                    .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
1719                    .collect::<Vec<_>>()
1720                    .join(", ")
1721            })
1722            .filter(|s| !s.is_empty())
1723            .map(|s| format!(" [{}]", s))
1724            .unwrap_or_default();
1725        let answered = if d.get("answer").map(|v| !v.is_null()).unwrap_or(false) {
1726            " [answered]"
1727        } else {
1728            ""
1729        };
1730        let is_closed = d.get("closed").and_then(|v| v.as_bool()).unwrap_or(false);
1731        let state_tag = if is_closed { " [closed]" } else { "" };
1732
1733        out.push_str(&format!(
1734            "  #{}{}{}{}{} {} — {} ({}{})\n",
1735            number, cat_tag, label_str, answered, state_tag, title, author, date, comments
1736        ));
1737    }
1738
1739    out.trim_end().to_string()
1740}
1741
1742pub fn list_issues_internal(
1743    repo: &str,
1744    kind: &str,
1745    state: &str,
1746    sort: &str,
1747    limit: usize,
1748    labels: Option<&str>,
1749) -> String {
1750    let per_page = limit.min(100);
1751    let direction = "desc";
1752
1753    match kind {
1754        "pr" => list_pulls(repo, state, sort, direction, per_page),
1755        "issue" => list_issues_only(repo, state, sort, direction, per_page, labels),
1756        "discussion" => list_discussions_graphql(repo, state, sort, per_page),
1757        _ => list_all(repo, state, sort, direction, per_page, labels),
1758    }
1759}
1760
1761fn list_pulls(repo: &str, state: &str, sort: &str, direction: &str, per_page: usize) -> String {
1762    let path = format!(
1763        "repos/{}/pulls?state={}&sort={}&direction={}&per_page={}",
1764        repo, state, sort, direction, per_page
1765    );
1766    match gh_get(&format!("{}/{}", GITHUB_API, path)) {
1767        Ok(Value::Array(items)) => format_pull_list(repo, state, &items),
1768        Ok(_) => "Unexpected response format.".to_string(),
1769        Err(e) => e,
1770    }
1771}
1772
1773fn list_issues_only(
1774    repo: &str,
1775    state: &str,
1776    sort: &str,
1777    direction: &str,
1778    per_page: usize,
1779    labels: Option<&str>,
1780) -> String {
1781    let mut path = format!(
1782        "repos/{}/issues?state={}&sort={}&direction={}&per_page={}",
1783        repo, state, sort, direction, per_page
1784    );
1785    if let Some(lbls) = labels {
1786        if !lbls.is_empty() {
1787            path.push_str(&format!("&labels={}", lbls));
1788        }
1789    }
1790    match gh_get(&format!("{}/{}", GITHUB_API, path)) {
1791        Ok(Value::Array(items)) => {
1792            // Filter out PRs (GitHub Issues API returns both)
1793            let issues: Vec<&Value> = items
1794                .iter()
1795                .filter(|item| item.get("pull_request").is_none())
1796                .collect();
1797            format_issue_list(repo, state, &issues)
1798        }
1799        Ok(_) => "Unexpected response format.".to_string(),
1800        Err(e) => e,
1801    }
1802}
1803
1804fn list_all(
1805    repo: &str,
1806    state: &str,
1807    sort: &str,
1808    direction: &str,
1809    per_page: usize,
1810    labels: Option<&str>,
1811) -> String {
1812    let mut path = format!(
1813        "repos/{}/issues?state={}&sort={}&direction={}&per_page={}",
1814        repo, state, sort, direction, per_page
1815    );
1816    if let Some(lbls) = labels {
1817        if !lbls.is_empty() {
1818            path.push_str(&format!("&labels={}", lbls));
1819        }
1820    }
1821    match gh_get(&format!("{}/{}", GITHUB_API, path)) {
1822        Ok(Value::Array(items)) => {
1823            let refs: Vec<&Value> = items.iter().collect();
1824            format_mixed_list(repo, state, &refs)
1825        }
1826        Ok(_) => "Unexpected response format.".to_string(),
1827        Err(e) => e,
1828    }
1829}
1830
1831// ---------------------------------------------------------------------------
1832// List formatting helpers
1833// ---------------------------------------------------------------------------
1834
1835fn format_label_tags(item: &Value) -> String {
1836    item.get("labels")
1837        .and_then(|v| v.as_array())
1838        .map(|arr| {
1839            arr.iter()
1840                .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
1841                .collect::<Vec<_>>()
1842                .join(", ")
1843        })
1844        .filter(|s| !s.is_empty())
1845        .map(|s| format!(" [{}]", s))
1846        .unwrap_or_default()
1847}
1848
1849fn format_date(item: &Value, key: &str) -> String {
1850    item.get(key)
1851        .and_then(|v| v.as_str())
1852        .map(|s| s.get(..10).unwrap_or(s).to_string())
1853        .unwrap_or_default()
1854}
1855
1856fn format_comments(item: &Value) -> String {
1857    let count = item.get("comments").and_then(|v| v.as_u64()).unwrap_or(0);
1858    if count > 0 {
1859        format!(", {} comment{}", count, if count == 1 { "" } else { "s" })
1860    } else {
1861        String::new()
1862    }
1863}
1864
1865fn format_issue_line(item: &Value) -> String {
1866    let number = item.get("number").and_then(|v| v.as_u64()).unwrap_or(0);
1867    let title = json_str(item, "title");
1868    let author = json_author(item);
1869    let labels = format_label_tags(item);
1870    let date = format_date(item, "created_at");
1871    let comments = format_comments(item);
1872    format!(
1873        "  #{}{} {} — {} ({}{})",
1874        number, labels, title, author, date, comments
1875    )
1876}
1877
1878fn format_pr_line(item: &Value) -> String {
1879    let number = item.get("number").and_then(|v| v.as_u64()).unwrap_or(0);
1880    let title = json_str(item, "title");
1881    let author = json_author(item);
1882    let labels = format_label_tags(item);
1883    let date = format_date(item, "created_at");
1884    let comments = format_comments(item);
1885    let draft = if item.get("draft").and_then(|v| v.as_bool()).unwrap_or(false) {
1886        " [draft]"
1887    } else {
1888        ""
1889    };
1890    let base = item
1891        .get("base")
1892        .and_then(|b| b.get("ref"))
1893        .and_then(|v| v.as_str())
1894        .unwrap_or("");
1895    let head = item
1896        .get("head")
1897        .and_then(|h| h.get("ref"))
1898        .and_then(|v| v.as_str())
1899        .unwrap_or("");
1900    let branch_info = if !base.is_empty() && !head.is_empty() {
1901        format!(" {} -> {}", head, base)
1902    } else {
1903        String::new()
1904    };
1905    format!(
1906        "  #{}{}{} {} — {} ({}{}){}",
1907        number, labels, draft, title, author, date, comments, branch_info
1908    )
1909}
1910
1911fn format_issue_list(repo: &str, state: &str, items: &[&Value]) -> String {
1912    if items.is_empty() {
1913        return format!("No {} issues in {}.", state, repo);
1914    }
1915    let mut out = format!(
1916        "{} issue{} in {} ({}):\n",
1917        items.len(),
1918        if items.len() == 1 { "" } else { "s" },
1919        repo,
1920        state
1921    );
1922    for item in items {
1923        out.push_str(&format_issue_line(item));
1924        out.push('\n');
1925    }
1926    out.trim_end().to_string()
1927}
1928
1929fn format_pull_list(repo: &str, state: &str, items: &[Value]) -> String {
1930    if items.is_empty() {
1931        return format!("No {} pull requests in {}.", state, repo);
1932    }
1933    let mut out = format!(
1934        "{} pull request{} in {} ({}):\n",
1935        items.len(),
1936        if items.len() == 1 { "" } else { "s" },
1937        repo,
1938        state
1939    );
1940    for item in items {
1941        out.push_str(&format_pr_line(item));
1942        out.push('\n');
1943    }
1944    out.trim_end().to_string()
1945}
1946
1947fn format_mixed_list(repo: &str, state: &str, items: &[&Value]) -> String {
1948    if items.is_empty() {
1949        return format!("No {} discussions in {}.", state, repo);
1950    }
1951    let mut out = format!(
1952        "{} discussion{} in {} ({}):\n",
1953        items.len(),
1954        if items.len() == 1 { "" } else { "s" },
1955        repo,
1956        state
1957    );
1958    for item in items {
1959        let is_pr = item.get("pull_request").is_some();
1960        if is_pr {
1961            // Issues API doesn't return full PR data (base/head), so format as issue with PR marker
1962            let number = item.get("number").and_then(|v| v.as_u64()).unwrap_or(0);
1963            let title = json_str(item, "title");
1964            let author = json_author(item);
1965            let labels = format_label_tags(item);
1966            let date = format_date(item, "created_at");
1967            let comments = format_comments(item);
1968            out.push_str(&format!(
1969                "  #{}{} [PR] {} — {} ({}{})\n",
1970                number, labels, title, author, date, comments
1971            ));
1972        } else {
1973            out.push_str(&format_issue_line(item));
1974            out.push('\n');
1975        }
1976    }
1977    out.trim_end().to_string()
1978}
1979
1980#[cfg(test)]
1981mod tests {
1982    use super::*;
1983
1984    /// Tests mutate process env; serialise to avoid cross-test races.
1985    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
1986        use std::sync::{Mutex, OnceLock};
1987        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1988        LOCK.get_or_init(|| Mutex::new(()))
1989            .lock()
1990            .unwrap_or_else(|p| p.into_inner())
1991    }
1992
1993    #[test]
1994    fn transient_status_classification() {
1995        // Retryable: 429 (rate limited) + the whole 5xx range.
1996        assert!(status_is_transient(429));
1997        assert!(status_is_transient(500));
1998        assert!(status_is_transient(502));
1999        assert!(status_is_transient(503));
2000        assert!(status_is_transient(599));
2001        // Non-transient: 2xx success + the 4xx statuses whose error
2002        // mapping must fire immediately (403 rate-limit hint, 404 "Not
2003        // found", 401 auth, 422 search-validation).
2004        assert!(!status_is_transient(200));
2005        assert!(!status_is_transient(401));
2006        assert!(!status_is_transient(403));
2007        assert!(!status_is_transient(404));
2008        assert!(!status_is_transient(422));
2009        assert!(!status_is_transient(600));
2010    }
2011
2012    #[test]
2013    fn backoff_doubles_then_caps() {
2014        // Base 500 → 1000 → 2000 → 4000 → 8000 → 16000 → 30000 (capped).
2015        assert_eq!(next_backoff_ms(BASE_BACKOFF_MS), 1_000);
2016        assert_eq!(next_backoff_ms(1_000), 2_000);
2017        assert_eq!(next_backoff_ms(2_000), 4_000);
2018        assert_eq!(next_backoff_ms(16_000), MAX_BACKOFF_MS);
2019        // Already at/over the ceiling stays clamped (no overflow).
2020        assert_eq!(next_backoff_ms(MAX_BACKOFF_MS), MAX_BACKOFF_MS);
2021        assert_eq!(next_backoff_ms(u64::MAX), MAX_BACKOFF_MS);
2022    }
2023
2024    #[test]
2025    fn empty_string_token_is_treated_as_missing() {
2026        let _g = env_lock();
2027        // Save original values so we can restore them after the test.
2028        let prev_gh_token = std::env::var("GITHUB_TOKEN").ok();
2029        let prev_alt_token = std::env::var("GH_TOKEN").ok();
2030
2031        unsafe {
2032            std::env::set_var("GITHUB_TOKEN", "");
2033            std::env::remove_var("GH_TOKEN");
2034        }
2035        assert!(
2036            !has_git_token(),
2037            "empty GITHUB_TOKEN must be treated as missing"
2038        );
2039
2040        unsafe {
2041            std::env::set_var("GITHUB_TOKEN", "ghp_real_value");
2042        }
2043        assert!(has_git_token(), "non-empty token must be detected");
2044
2045        // Restore.
2046        unsafe {
2047            match prev_gh_token {
2048                Some(v) => std::env::set_var("GITHUB_TOKEN", v),
2049                None => std::env::remove_var("GITHUB_TOKEN"),
2050            }
2051            match prev_alt_token {
2052                Some(v) => std::env::set_var("GH_TOKEN", v),
2053                None => std::env::remove_var("GH_TOKEN"),
2054            }
2055        }
2056    }
2057
2058    #[test]
2059    fn leading_slash_paths_normalise() {
2060        // A leading slash is how the GitHub REST docs render endpoints; it
2061        // must not change the resulting URL.
2062        assert_eq!(
2063            build_git_api_url("someorg/somerepo", "/repos/kkollsga/kglite"),
2064            "https://api.github.com/repos/kkollsga/kglite",
2065        );
2066        assert_eq!(
2067            build_git_api_url("someorg/somerepo", "repos/kkollsga/kglite"),
2068            "https://api.github.com/repos/kkollsga/kglite",
2069        );
2070        // Relative paths still get wrapped in /repos/<repo>/, slash or not.
2071        assert_eq!(
2072            build_git_api_url("o/r", "/pulls?state=open"),
2073            "https://api.github.com/repos/o/r/pulls?state=open",
2074        );
2075        assert_eq!(
2076            build_git_api_url("o/r", "pulls?state=open"),
2077            "https://api.github.com/repos/o/r/pulls?state=open",
2078        );
2079        // search/ is a top-level resource in both forms.
2080        assert_eq!(
2081            build_git_api_url("o/r", "/search/issues?q=foo"),
2082            "https://api.github.com/search/issues?q=foo",
2083        );
2084    }
2085}