github_fetch/
discussion.rs1use 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}