Skip to main content

perfgate_github/
client.rs

1//! GitHub REST API client for managing PR comments.
2
3use crate::error::GitHubError;
4use crate::types::{GitHubComment, GitHubCommentRequest};
5use reqwest::header::{self, HeaderMap, HeaderValue};
6use tracing::debug;
7
8/// Marker embedded in PR comments to identify perfgate comments for idempotent updates.
9pub const COMMENT_MARKER: &str = "<!-- perfgate -->";
10
11/// Client for the GitHub REST API, focused on issue/PR comments.
12#[derive(Clone, Debug)]
13pub struct GitHubClient {
14    base_url: String,
15    inner: reqwest::Client,
16}
17
18impl GitHubClient {
19    /// Creates a new GitHubClient with the given token.
20    ///
21    /// The `base_url` should be `https://api.github.com` for github.com
22    /// or `https://github.example.com/api/v3` for GitHub Enterprise.
23    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    /// List comments on a pull request / issue.
54    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    /// Create a new comment on a pull request / issue.
102    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    /// Update an existing comment.
140    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    /// Find an existing perfgate comment on a PR by looking for the marker.
178    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    /// Create or update the perfgate comment on a PR (idempotent).
191    ///
192    /// If a comment with the perfgate marker already exists, it is updated.
193    /// Otherwise, a new comment is created.
194    ///
195    /// Returns `(comment, created)` where `created` is true if a new comment was made.
196    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        // List returns empty
350        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        // Create succeeds
357        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        // List returns existing perfgate comment
383        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        // Update succeeds
397        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}