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
11const 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;
19const COMMENT_HEAD_PAGES: usize = 5;
21const COMMENT_TAIL_PAGES: usize = 5;
22const 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
35static AGENT: LazyLock<ureq::Agent> = LazyLock::new(|| {
37 ureq::AgentBuilder::new()
38 .timeout(Duration::from_secs(30))
39 .build()
40});
41
42const RETRY_COUNT: usize = 3;
56const BASE_BACKOFF_MS: u64 = 500;
58const MAX_BACKOFF_MS: u64 = 30_000;
60
61fn status_is_transient(code: u16) -> bool {
74 code == 429 || (500..=599).contains(&code)
75}
76
77fn 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
87fn next_backoff_ms(current: u64) -> u64 {
89 current.saturating_mul(2).min(MAX_BACKOFF_MS)
90}
91
92#[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
129pub 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 let s = n.to_string();
143 s.len()
144 }
145 Value::String(s) => s.len() + 2, 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
156fn auth_token() -> Option<String> {
161 env::var("GITHUB_TOKEN")
167 .or_else(|_| env::var("GH_TOKEN"))
168 .ok()
169 .filter(|s| !s.is_empty())
170}
171
172pub fn has_git_token() -> bool {
174 auth_token().is_some()
175}
176
177pub 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#[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 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
313fn parse_last_page(link: &str) -> Option<usize> {
315 let url = parse_link_rel(link, "last")?;
316 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#[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#[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 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 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 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 all_items.push(json!({"_skipped_middle": true}));
447 }
448
449 Ok(all_items)
450}
451
452fn 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 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
635const 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 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
833fn 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 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 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
866fn fetch_single_discussion(
871 repo: &str,
872 number: u64,
873 include_files: bool,
874 include_timeline: bool,
875) -> Result<Value, String> {
876 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 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 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 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 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
1052fn 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 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 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
1131pub 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 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 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 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 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 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
1194fn build_git_api_url(repo: &str, path: &str) -> String {
1207 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#[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
1299fn 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 _ => {} }
1311 match state {
1312 "open" => q.push_str(" state:open"),
1313 "closed" => q.push_str(" state:closed"),
1314 _ => {} }
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#[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 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
1394fn 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 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; }
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
1524pub 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 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, (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
1564fn 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
1606fn 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 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 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
1831fn 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 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 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 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 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 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 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 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 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 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 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 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}