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