1use crate::error::GitHubError;
4use crate::types::{GitHubComment, GitHubCommentRequest};
5use reqwest::header::{self, HeaderMap, HeaderValue};
6use tracing::debug;
7
8pub const COMMENT_MARKER: &str = "<!-- perfgate -->";
10
11#[derive(Clone, Debug)]
13pub struct GitHubClient {
14 base_url: String,
15 inner: reqwest::Client,
16}
17
18impl GitHubClient {
19 pub fn new(base_url: &str, token: &str) -> Result<Self, GitHubError> {
24 let mut headers = HeaderMap::new();
25
26 let mut auth_value = HeaderValue::from_str(&format!("Bearer {}", token))
27 .map_err(|e| GitHubError::Config(format!("Invalid token header: {}", e)))?;
28 auth_value.set_sensitive(true);
29 headers.insert(header::AUTHORIZATION, auth_value);
30
31 headers.insert(
32 header::ACCEPT,
33 HeaderValue::from_static("application/vnd.github+json"),
34 );
35 headers.insert(
36 "X-GitHub-Api-Version",
37 HeaderValue::from_static("2022-11-28"),
38 );
39 headers.insert(header::USER_AGENT, HeaderValue::from_static("perfgate-bot"));
40
41 let inner = reqwest::Client::builder()
42 .default_headers(headers)
43 .timeout(std::time::Duration::from_secs(30))
44 .build()
45 .map_err(|e| GitHubError::Config(format!("Failed to build HTTP client: {}", e)))?;
46
47 Ok(Self {
48 base_url: base_url.trim_end_matches('/').to_string(),
49 inner,
50 })
51 }
52
53 pub async fn list_comments(
55 &self,
56 owner: &str,
57 repo: &str,
58 pr_number: u64,
59 ) -> Result<Vec<GitHubComment>, GitHubError> {
60 let mut all_comments = Vec::new();
61 let mut page = 1u32;
62
63 loop {
64 let url = format!(
65 "{}/repos/{}/{}/issues/{}/comments?per_page=100&page={}",
66 self.base_url, owner, repo, pr_number, page,
67 );
68 debug!(url = %url, "Listing PR comments");
69
70 let response = self
71 .inner
72 .get(&url)
73 .send()
74 .await
75 .map_err(GitHubError::Request)?;
76
77 if !response.status().is_success() {
78 let status = response.status().as_u16();
79 let body = response.text().await.unwrap_or_default();
80 return Err(GitHubError::Api {
81 status,
82 message: body,
83 });
84 }
85
86 let comments: Vec<GitHubComment> =
87 response.json().await.map_err(GitHubError::Request)?;
88
89 let is_last = comments.len() < 100;
90 all_comments.extend(comments);
91
92 if is_last {
93 break;
94 }
95 page += 1;
96 }
97
98 Ok(all_comments)
99 }
100
101 pub async fn create_comment(
103 &self,
104 owner: &str,
105 repo: &str,
106 pr_number: u64,
107 body: &str,
108 ) -> Result<GitHubComment, GitHubError> {
109 let url = format!(
110 "{}/repos/{}/{}/issues/{}/comments",
111 self.base_url, owner, repo, pr_number,
112 );
113 debug!(url = %url, "Creating PR comment");
114
115 let request = GitHubCommentRequest {
116 body: body.to_string(),
117 };
118
119 let response = self
120 .inner
121 .post(&url)
122 .json(&request)
123 .send()
124 .await
125 .map_err(GitHubError::Request)?;
126
127 if !response.status().is_success() {
128 let status = response.status().as_u16();
129 let body = response.text().await.unwrap_or_default();
130 return Err(GitHubError::Api {
131 status,
132 message: body,
133 });
134 }
135
136 response.json().await.map_err(GitHubError::Request)
137 }
138
139 pub async fn update_comment(
141 &self,
142 owner: &str,
143 repo: &str,
144 comment_id: u64,
145 body: &str,
146 ) -> Result<GitHubComment, GitHubError> {
147 let url = format!(
148 "{}/repos/{}/{}/issues/comments/{}",
149 self.base_url, owner, repo, comment_id,
150 );
151 debug!(url = %url, comment_id = comment_id, "Updating PR comment");
152
153 let request = GitHubCommentRequest {
154 body: body.to_string(),
155 };
156
157 let response = self
158 .inner
159 .patch(&url)
160 .json(&request)
161 .send()
162 .await
163 .map_err(GitHubError::Request)?;
164
165 if !response.status().is_success() {
166 let status = response.status().as_u16();
167 let body = response.text().await.unwrap_or_default();
168 return Err(GitHubError::Api {
169 status,
170 message: body,
171 });
172 }
173
174 response.json().await.map_err(GitHubError::Request)
175 }
176
177 pub async fn find_perfgate_comment(
179 &self,
180 owner: &str,
181 repo: &str,
182 pr_number: u64,
183 ) -> Result<Option<GitHubComment>, GitHubError> {
184 let comments = self.list_comments(owner, repo, pr_number).await?;
185 Ok(comments
186 .into_iter()
187 .find(|c| c.body.contains(COMMENT_MARKER)))
188 }
189
190 pub async fn upsert_comment(
197 &self,
198 owner: &str,
199 repo: &str,
200 pr_number: u64,
201 body: &str,
202 ) -> Result<(GitHubComment, bool), GitHubError> {
203 let existing = self.find_perfgate_comment(owner, repo, pr_number).await?;
204
205 match existing {
206 Some(comment) => {
207 debug!(
208 comment_id = comment.id,
209 "Updating existing perfgate comment"
210 );
211 let updated = self.update_comment(owner, repo, comment.id, body).await?;
212 Ok((updated, false))
213 }
214 None => {
215 debug!("Creating new perfgate comment");
216 let created = self.create_comment(owner, repo, pr_number, body).await?;
217 Ok((created, true))
218 }
219 }
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use wiremock::matchers::{bearer_token, header, method, path};
227 use wiremock::{Mock, MockServer, ResponseTemplate};
228
229 #[tokio::test]
230 async fn test_create_comment() {
231 let mock_server = MockServer::start().await;
232
233 Mock::given(method("POST"))
234 .and(path("/repos/owner/repo/issues/1/comments"))
235 .and(bearer_token("test-token"))
236 .and(header("Accept", "application/vnd.github+json"))
237 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
238 "id": 42,
239 "body": "test body",
240 "html_url": "https://github.com/owner/repo/pull/1#issuecomment-42",
241 "user": {
242 "login": "perfgate-bot"
243 }
244 })))
245 .mount(&mock_server)
246 .await;
247
248 let client = GitHubClient::new(&mock_server.uri(), "test-token").unwrap();
249 let comment = client
250 .create_comment("owner", "repo", 1, "test body")
251 .await
252 .unwrap();
253
254 assert_eq!(comment.id, 42);
255 assert_eq!(comment.body, "test body");
256 }
257
258 #[tokio::test]
259 async fn test_update_comment() {
260 let mock_server = MockServer::start().await;
261
262 Mock::given(method("PATCH"))
263 .and(path("/repos/owner/repo/issues/comments/42"))
264 .and(bearer_token("test-token"))
265 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
266 "id": 42,
267 "body": "updated body",
268 "html_url": "https://github.com/owner/repo/pull/1#issuecomment-42",
269 "user": {
270 "login": "perfgate-bot"
271 }
272 })))
273 .mount(&mock_server)
274 .await;
275
276 let client = GitHubClient::new(&mock_server.uri(), "test-token").unwrap();
277 let comment = client
278 .update_comment("owner", "repo", 42, "updated body")
279 .await
280 .unwrap();
281
282 assert_eq!(comment.id, 42);
283 assert_eq!(comment.body, "updated body");
284 }
285
286 #[tokio::test]
287 async fn test_find_perfgate_comment() {
288 let mock_server = MockServer::start().await;
289
290 Mock::given(method("GET"))
291 .and(path("/repos/owner/repo/issues/1/comments"))
292 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
293 {
294 "id": 1,
295 "body": "unrelated comment",
296 "html_url": "https://github.com/owner/repo/pull/1#issuecomment-1",
297 "user": { "login": "someone" }
298 },
299 {
300 "id": 2,
301 "body": "<!-- perfgate -->\nperfgate results",
302 "html_url": "https://github.com/owner/repo/pull/1#issuecomment-2",
303 "user": { "login": "perfgate-bot" }
304 }
305 ])))
306 .mount(&mock_server)
307 .await;
308
309 let client = GitHubClient::new(&mock_server.uri(), "test-token").unwrap();
310 let found = client
311 .find_perfgate_comment("owner", "repo", 1)
312 .await
313 .unwrap();
314
315 assert!(found.is_some());
316 assert_eq!(found.unwrap().id, 2);
317 }
318
319 #[tokio::test]
320 async fn test_find_perfgate_comment_not_found() {
321 let mock_server = MockServer::start().await;
322
323 Mock::given(method("GET"))
324 .and(path("/repos/owner/repo/issues/1/comments"))
325 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
326 {
327 "id": 1,
328 "body": "no marker here",
329 "html_url": "https://github.com/owner/repo/pull/1#issuecomment-1",
330 "user": { "login": "someone" }
331 }
332 ])))
333 .mount(&mock_server)
334 .await;
335
336 let client = GitHubClient::new(&mock_server.uri(), "test-token").unwrap();
337 let found = client
338 .find_perfgate_comment("owner", "repo", 1)
339 .await
340 .unwrap();
341
342 assert!(found.is_none());
343 }
344
345 #[tokio::test]
346 async fn test_upsert_creates_when_no_existing() {
347 let mock_server = MockServer::start().await;
348
349 Mock::given(method("GET"))
351 .and(path("/repos/owner/repo/issues/1/comments"))
352 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
353 .mount(&mock_server)
354 .await;
355
356 Mock::given(method("POST"))
358 .and(path("/repos/owner/repo/issues/1/comments"))
359 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
360 "id": 99,
361 "body": "new comment",
362 "html_url": "https://github.com/owner/repo/pull/1#issuecomment-99",
363 "user": { "login": "perfgate-bot" }
364 })))
365 .mount(&mock_server)
366 .await;
367
368 let client = GitHubClient::new(&mock_server.uri(), "test-token").unwrap();
369 let (comment, created) = client
370 .upsert_comment("owner", "repo", 1, "new comment")
371 .await
372 .unwrap();
373
374 assert!(created);
375 assert_eq!(comment.id, 99);
376 }
377
378 #[tokio::test]
379 async fn test_upsert_updates_when_existing() {
380 let mock_server = MockServer::start().await;
381
382 Mock::given(method("GET"))
384 .and(path("/repos/owner/repo/issues/1/comments"))
385 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
386 {
387 "id": 50,
388 "body": "<!-- perfgate -->\nold content",
389 "html_url": "https://github.com/owner/repo/pull/1#issuecomment-50",
390 "user": { "login": "perfgate-bot" }
391 }
392 ])))
393 .mount(&mock_server)
394 .await;
395
396 Mock::given(method("PATCH"))
398 .and(path("/repos/owner/repo/issues/comments/50"))
399 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
400 "id": 50,
401 "body": "<!-- perfgate -->\nnew content",
402 "html_url": "https://github.com/owner/repo/pull/1#issuecomment-50",
403 "user": { "login": "perfgate-bot" }
404 })))
405 .mount(&mock_server)
406 .await;
407
408 let client = GitHubClient::new(&mock_server.uri(), "test-token").unwrap();
409 let (comment, created) = client
410 .upsert_comment("owner", "repo", 1, "<!-- perfgate -->\nnew content")
411 .await
412 .unwrap();
413
414 assert!(!created);
415 assert_eq!(comment.id, 50);
416 }
417
418 #[tokio::test]
419 async fn test_api_error() {
420 let mock_server = MockServer::start().await;
421
422 Mock::given(method("POST"))
423 .and(path("/repos/owner/repo/issues/1/comments"))
424 .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({
425 "message": "Resource not accessible by integration"
426 })))
427 .mount(&mock_server)
428 .await;
429
430 let client = GitHubClient::new(&mock_server.uri(), "test-token").unwrap();
431 let result = client.create_comment("owner", "repo", 1, "test").await;
432
433 assert!(result.is_err());
434 let err = result.unwrap_err();
435 assert!(matches!(err, GitHubError::Api { status: 403, .. }));
436 }
437}