github_fetch/
discussion.rs

1use chrono::{DateTime, Utc};
2use log::info;
3use regex::Regex;
4use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT};
5use serde_json::json;
6
7use crate::config::GitHubConfig;
8use crate::error::{GitHubFetchError, Result};
9use crate::types::{Discussion, DiscussionComment, GitHubUser, Repository};
10
11pub struct DiscussionClient {
12    client: reqwest::Client,
13    config: GitHubConfig,
14}
15
16impl DiscussionClient {
17    pub fn new(config: GitHubConfig) -> Result<Self> {
18        let client = reqwest::Client::new();
19        Ok(Self { client, config })
20    }
21
22    pub async fn fetch_discussion(
23        &self,
24        repo: &Repository,
25        discussion_number: u64,
26    ) -> Result<Discussion> {
27        info!(
28            "Fetching discussion data for {}/{} #{}",
29            repo.owner, repo.name, discussion_number
30        );
31
32        let token = std::env::var(&self.config.token_env_var).map_err(|_| {
33            GitHubFetchError::AuthError(format!(
34                "{} environment variable not set",
35                self.config.token_env_var
36            ))
37        })?;
38
39        let query = self.build_discussion_query(&repo.owner, &repo.name, discussion_number);
40
41        let mut headers = HeaderMap::new();
42        headers.insert(
43            AUTHORIZATION,
44            HeaderValue::from_str(&format!("Bearer {}", token))
45                .map_err(|e| GitHubFetchError::ConfigError(format!("Invalid token: {}", e)))?,
46        );
47        headers.insert(
48            USER_AGENT,
49            HeaderValue::from_str(&self.config.user_agent)
50                .map_err(|e| GitHubFetchError::ConfigError(format!("Invalid user agent: {}", e)))?,
51        );
52        headers.insert(
53            "Accept",
54            HeaderValue::from_static("application/vnd.github+json"),
55        );
56
57        let request_body = json!({
58            "query": query
59        });
60
61        let response = self
62            .client
63            .post("https://api.github.com/graphql")
64            .headers(headers)
65            .json(&request_body)
66            .send()
67            .await?;
68
69        if !response.status().is_success() {
70            let error_text = response.text().await.unwrap_or_default();
71            return Err(GitHubFetchError::ApiError(format!(
72                "GitHub GraphQL API request failed: {}",
73                error_text
74            )));
75        }
76
77        let response_json: serde_json::Value = response.json().await?;
78
79        self.parse_discussion_response(response_json, repo, discussion_number)
80    }
81
82    pub async fn fetch_discussion_by_url(&self, discussion_url: &str) -> Result<Discussion> {
83        let (owner, repo, discussion_number) = self.parse_discussion_url(discussion_url)?;
84        let repository = Repository::new(owner, repo);
85        self.fetch_discussion(&repository, discussion_number).await
86    }
87
88    fn parse_discussion_url(&self, url: &str) -> Result<(String, String, u64)> {
89        let re = Regex::new(r"https://github\.com/([^/]+)/([^/]+)/discussions/(\d+)")
90            .map_err(|e| GitHubFetchError::ConfigError(format!("Invalid regex: {}", e)))?;
91
92        if let Some(captures) = re.captures(url) {
93            let owner = captures.get(1).unwrap().as_str().to_string();
94            let repo = captures.get(2).unwrap().as_str().to_string();
95            let discussion_number: u64 =
96                captures.get(3).unwrap().as_str().parse().map_err(|e| {
97                    GitHubFetchError::InvalidRepository(format!("Invalid discussion number: {}", e))
98                })?;
99            Ok((owner, repo, discussion_number))
100        } else {
101            Err(GitHubFetchError::InvalidRepository(format!(
102                "Invalid GitHub discussion URL format: {}",
103                url
104            )))
105        }
106    }
107
108    fn build_discussion_query(&self, owner: &str, repo: &str, discussion_number: u64) -> String {
109        format!(
110            r#"
111    {{
112        repository(owner: "{}", name: "{}") {{
113            discussion(number: {}) {{
114                number
115                title
116                body
117                url
118                author {{
119                    login
120                    ... on User {{
121                        id
122                        avatarUrl
123                    }}
124                }}
125                createdAt
126                updatedAt
127                comments(first: 100) {{
128                    nodes {{
129                        id
130                        body
131                        author {{
132                            login
133                            ... on User {{
134                                id
135                                avatarUrl
136                            }}
137                        }}
138                        createdAt
139                        updatedAt
140                    }}
141                }}
142            }}
143        }}
144    }}"#,
145            owner, repo, discussion_number
146        )
147    }
148
149    fn parse_discussion_response(
150        &self,
151        response_json: serde_json::Value,
152        repo: &Repository,
153        discussion_number: u64,
154    ) -> Result<Discussion> {
155        let discussion_json = response_json
156            .get("data")
157            .and_then(|d| d.get("repository"))
158            .and_then(|r| r.get("discussion"))
159            .ok_or_else(|| {
160                GitHubFetchError::NotFound(format!(
161                    "Discussion #{} not found in {}/{}",
162                    discussion_number, repo.owner, repo.name
163                ))
164            })?;
165
166        let comments: Vec<DiscussionComment> = discussion_json
167            .get("comments")
168            .and_then(|c| c.get("nodes"))
169            .and_then(|nodes| nodes.as_array())
170            .map(|nodes| {
171                nodes
172                    .iter()
173                    .filter_map(|comment| {
174                        Some(DiscussionComment {
175                            id: comment.get("id")?.as_str()?.to_string(),
176                            body: comment.get("body")?.as_str()?.to_string(),
177                            author: GitHubUser {
178                                id: comment.get("author")?.get("id")?.as_str()?.parse().ok()?,
179                                login: comment.get("author")?.get("login")?.as_str()?.to_string(),
180                                avatar_url: comment
181                                    .get("author")?
182                                    .get("avatarUrl")?
183                                    .as_str()?
184                                    .to_string(),
185                            },
186                            created_at: comment
187                                .get("createdAt")?
188                                .as_str()?
189                                .parse::<DateTime<Utc>>()
190                                .ok()?,
191                            updated_at: comment
192                                .get("updatedAt")?
193                                .as_str()?
194                                .parse::<DateTime<Utc>>()
195                                .ok()?,
196                        })
197                    })
198                    .collect()
199            })
200            .unwrap_or_default();
201
202        let number = discussion_json
203            .get("number")
204            .and_then(|n| n.as_u64())
205            .unwrap_or(discussion_number);
206
207        let author_json = discussion_json.get("author");
208        let author = if let Some(author) = author_json {
209            GitHubUser {
210                id: author
211                    .get("id")
212                    .and_then(|id| id.as_str())
213                    .and_then(|s| s.parse().ok())
214                    .unwrap_or(0),
215                login: author
216                    .get("login")
217                    .and_then(|l| l.as_str())
218                    .unwrap_or("unknown")
219                    .to_string(),
220                avatar_url: author
221                    .get("avatarUrl")
222                    .and_then(|u| u.as_str())
223                    .unwrap_or("")
224                    .to_string(),
225            }
226        } else {
227            GitHubUser {
228                id: 0,
229                login: "unknown".to_string(),
230                avatar_url: "".to_string(),
231            }
232        };
233
234        Ok(Discussion {
235            number,
236            title: discussion_json
237                .get("title")
238                .and_then(|t| t.as_str())
239                .unwrap_or("Unknown Discussion")
240                .to_string(),
241            body: discussion_json
242                .get("body")
243                .and_then(|b| b.as_str())
244                .unwrap_or("")
245                .to_string(),
246            url: discussion_json
247                .get("url")
248                .and_then(|u| u.as_str())
249                .unwrap_or("")
250                .to_string(),
251            author,
252            created_at: discussion_json
253                .get("createdAt")
254                .and_then(|c| c.as_str())
255                .and_then(|s| s.parse::<DateTime<Utc>>().ok())
256                .unwrap_or_else(Utc::now),
257            updated_at: discussion_json
258                .get("updatedAt")
259                .and_then(|u| u.as_str())
260                .and_then(|s| s.parse::<DateTime<Utc>>().ok())
261                .unwrap_or_else(Utc::now),
262            comments,
263        })
264    }
265}