github-fetch 0.1.0

A comprehensive GitHub API client for fetching issues, PRs, reviews, discussions, and diffs with filtering support
Documentation
use chrono::{DateTime, Utc};
use log::info;
use regex::Regex;
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT};
use serde_json::json;

use crate::config::GitHubConfig;
use crate::error::{GitHubFetchError, Result};
use crate::types::{Discussion, DiscussionComment, GitHubUser, Repository};

pub struct DiscussionClient {
    client: reqwest::Client,
    config: GitHubConfig,
}

impl DiscussionClient {
    pub fn new(config: GitHubConfig) -> Result<Self> {
        let client = reqwest::Client::new();
        Ok(Self { client, config })
    }

    pub async fn fetch_discussion(
        &self,
        repo: &Repository,
        discussion_number: u64,
    ) -> Result<Discussion> {
        info!(
            "Fetching discussion data for {}/{} #{}",
            repo.owner, repo.name, discussion_number
        );

        let token = std::env::var(&self.config.token_env_var).map_err(|_| {
            GitHubFetchError::AuthError(format!(
                "{} environment variable not set",
                self.config.token_env_var
            ))
        })?;

        let query = self.build_discussion_query(&repo.owner, &repo.name, discussion_number);

        let mut headers = HeaderMap::new();
        headers.insert(
            AUTHORIZATION,
            HeaderValue::from_str(&format!("Bearer {}", token))
                .map_err(|e| GitHubFetchError::ConfigError(format!("Invalid token: {}", e)))?,
        );
        headers.insert(
            USER_AGENT,
            HeaderValue::from_str(&self.config.user_agent)
                .map_err(|e| GitHubFetchError::ConfigError(format!("Invalid user agent: {}", e)))?,
        );
        headers.insert(
            "Accept",
            HeaderValue::from_static("application/vnd.github+json"),
        );

        let request_body = json!({
            "query": query
        });

        let response = self
            .client
            .post("https://api.github.com/graphql")
            .headers(headers)
            .json(&request_body)
            .send()
            .await?;

        if !response.status().is_success() {
            let error_text = response.text().await.unwrap_or_default();
            return Err(GitHubFetchError::ApiError(format!(
                "GitHub GraphQL API request failed: {}",
                error_text
            )));
        }

        let response_json: serde_json::Value = response.json().await?;

        self.parse_discussion_response(response_json, repo, discussion_number)
    }

    pub async fn fetch_discussion_by_url(&self, discussion_url: &str) -> Result<Discussion> {
        let (owner, repo, discussion_number) = self.parse_discussion_url(discussion_url)?;
        let repository = Repository::new(owner, repo);
        self.fetch_discussion(&repository, discussion_number).await
    }

    fn parse_discussion_url(&self, url: &str) -> Result<(String, String, u64)> {
        let re = Regex::new(r"https://github\.com/([^/]+)/([^/]+)/discussions/(\d+)")
            .map_err(|e| GitHubFetchError::ConfigError(format!("Invalid regex: {}", e)))?;

        if let Some(captures) = re.captures(url) {
            let owner = captures.get(1).unwrap().as_str().to_string();
            let repo = captures.get(2).unwrap().as_str().to_string();
            let discussion_number: u64 =
                captures.get(3).unwrap().as_str().parse().map_err(|e| {
                    GitHubFetchError::InvalidRepository(format!("Invalid discussion number: {}", e))
                })?;
            Ok((owner, repo, discussion_number))
        } else {
            Err(GitHubFetchError::InvalidRepository(format!(
                "Invalid GitHub discussion URL format: {}",
                url
            )))
        }
    }

    fn build_discussion_query(&self, owner: &str, repo: &str, discussion_number: u64) -> String {
        format!(
            r#"
    {{
        repository(owner: "{}", name: "{}") {{
            discussion(number: {}) {{
                number
                title
                body
                url
                author {{
                    login
                    ... on User {{
                        id
                        avatarUrl
                    }}
                }}
                createdAt
                updatedAt
                comments(first: 100) {{
                    nodes {{
                        id
                        body
                        author {{
                            login
                            ... on User {{
                                id
                                avatarUrl
                            }}
                        }}
                        createdAt
                        updatedAt
                    }}
                }}
            }}
        }}
    }}"#,
            owner, repo, discussion_number
        )
    }

    fn parse_discussion_response(
        &self,
        response_json: serde_json::Value,
        repo: &Repository,
        discussion_number: u64,
    ) -> Result<Discussion> {
        let discussion_json = response_json
            .get("data")
            .and_then(|d| d.get("repository"))
            .and_then(|r| r.get("discussion"))
            .ok_or_else(|| {
                GitHubFetchError::NotFound(format!(
                    "Discussion #{} not found in {}/{}",
                    discussion_number, repo.owner, repo.name
                ))
            })?;

        let comments: Vec<DiscussionComment> = discussion_json
            .get("comments")
            .and_then(|c| c.get("nodes"))
            .and_then(|nodes| nodes.as_array())
            .map(|nodes| {
                nodes
                    .iter()
                    .filter_map(|comment| {
                        Some(DiscussionComment {
                            id: comment.get("id")?.as_str()?.to_string(),
                            body: comment.get("body")?.as_str()?.to_string(),
                            author: GitHubUser {
                                id: comment.get("author")?.get("id")?.as_str()?.parse().ok()?,
                                login: comment.get("author")?.get("login")?.as_str()?.to_string(),
                                avatar_url: comment
                                    .get("author")?
                                    .get("avatarUrl")?
                                    .as_str()?
                                    .to_string(),
                            },
                            created_at: comment
                                .get("createdAt")?
                                .as_str()?
                                .parse::<DateTime<Utc>>()
                                .ok()?,
                            updated_at: comment
                                .get("updatedAt")?
                                .as_str()?
                                .parse::<DateTime<Utc>>()
                                .ok()?,
                        })
                    })
                    .collect()
            })
            .unwrap_or_default();

        let number = discussion_json
            .get("number")
            .and_then(|n| n.as_u64())
            .unwrap_or(discussion_number);

        let author_json = discussion_json.get("author");
        let author = if let Some(author) = author_json {
            GitHubUser {
                id: author
                    .get("id")
                    .and_then(|id| id.as_str())
                    .and_then(|s| s.parse().ok())
                    .unwrap_or(0),
                login: author
                    .get("login")
                    .and_then(|l| l.as_str())
                    .unwrap_or("unknown")
                    .to_string(),
                avatar_url: author
                    .get("avatarUrl")
                    .and_then(|u| u.as_str())
                    .unwrap_or("")
                    .to_string(),
            }
        } else {
            GitHubUser {
                id: 0,
                login: "unknown".to_string(),
                avatar_url: "".to_string(),
            }
        };

        Ok(Discussion {
            number,
            title: discussion_json
                .get("title")
                .and_then(|t| t.as_str())
                .unwrap_or("Unknown Discussion")
                .to_string(),
            body: discussion_json
                .get("body")
                .and_then(|b| b.as_str())
                .unwrap_or("")
                .to_string(),
            url: discussion_json
                .get("url")
                .and_then(|u| u.as_str())
                .unwrap_or("")
                .to_string(),
            author,
            created_at: discussion_json
                .get("createdAt")
                .and_then(|c| c.as_str())
                .and_then(|s| s.parse::<DateTime<Utc>>().ok())
                .unwrap_or_else(Utc::now),
            updated_at: discussion_json
                .get("updatedAt")
                .and_then(|u| u.as_str())
                .and_then(|s| s.parse::<DateTime<Utc>>().ok())
                .unwrap_or_else(Utc::now),
            comments,
        })
    }
}