1use serde::Deserialize;
4
5#[derive(Debug, thiserror::Error)]
6pub enum GithubError {
7 #[error("github http error: {0}")]
8 Http(#[from] reqwest::Error),
9 #[error("github api returned {status}: {body}")]
10 UnexpectedStatus { status: u16, body: String },
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum CommentAction {
15 Created,
16 Updated,
17}
18
19pub struct GithubClient {
20 client: reqwest::blocking::Client,
21 base_url: String,
22 repo: String,
23 token: String,
24}
25
26#[derive(Deserialize)]
27struct IssueComment {
28 id: u64,
29 body: Option<String>,
30}
31
32const PER_PAGE: u32 = 100;
33const MAX_PAGES: u32 = 10;
34
35impl GithubClient {
36 pub fn new(repo: impl Into<String>, token: impl Into<String>) -> Self {
37 Self::with_base_url(repo, token, "https://api.github.com")
38 }
39
40 pub fn with_base_url(
41 repo: impl Into<String>,
42 token: impl Into<String>,
43 base_url: impl Into<String>,
44 ) -> Self {
45 Self {
46 client: reqwest::blocking::Client::new(),
47 base_url: base_url.into().trim_end_matches('/').to_string(),
48 repo: repo.into(),
49 token: token.into(),
50 }
51 }
52
53 pub fn upsert_pr_comment(
56 &self,
57 pr_number: u64,
58 marker: &str,
59 body: &str,
60 ) -> Result<CommentAction, GithubError> {
61 if let Some(comment_id) = self.find_comment(pr_number, marker)? {
62 let url = format!(
63 "{}/repos/{}/issues/comments/{comment_id}",
64 self.base_url, self.repo
65 );
66 let resp = self
67 .request(self.client.patch(&url))
68 .json(&serde_json::json!({ "body": body }))
69 .send()?;
70 expect_success(resp)?;
71 Ok(CommentAction::Updated)
72 } else {
73 let url = format!(
74 "{}/repos/{}/issues/{pr_number}/comments",
75 self.base_url, self.repo
76 );
77 let resp = self
78 .request(self.client.post(&url))
79 .json(&serde_json::json!({ "body": body }))
80 .send()?;
81 expect_success(resp)?;
82 Ok(CommentAction::Created)
83 }
84 }
85
86 fn find_comment(&self, pr_number: u64, marker: &str) -> Result<Option<u64>, GithubError> {
87 for page in 1..=MAX_PAGES {
88 let url = format!(
89 "{}/repos/{}/issues/{pr_number}/comments?per_page={PER_PAGE}&page={page}",
90 self.base_url, self.repo
91 );
92 let resp = self.request(self.client.get(&url)).send()?;
93 let resp = expect_success(resp)?;
94 let comments: Vec<IssueComment> = resp.json()?;
95 let count = comments.len();
96 for comment in comments {
97 if comment.body.is_some_and(|b| b.contains(marker)) {
98 return Ok(Some(comment.id));
99 }
100 }
101 if count < PER_PAGE as usize {
102 break;
103 }
104 }
105 Ok(None)
106 }
107
108 fn request(
109 &self,
110 builder: reqwest::blocking::RequestBuilder,
111 ) -> reqwest::blocking::RequestBuilder {
112 builder
113 .bearer_auth(&self.token)
114 .header("accept", "application/vnd.github+json")
115 .header("x-github-api-version", "2022-11-28")
116 .header("user-agent", "badgers")
117 }
118}
119
120fn expect_success(
121 resp: reqwest::blocking::Response,
122) -> Result<reqwest::blocking::Response, GithubError> {
123 let status = resp.status();
124 if status.is_success() {
125 Ok(resp)
126 } else {
127 Err(GithubError::UnexpectedStatus {
128 status: status.as_u16(),
129 body: resp.text().unwrap_or_default(),
130 })
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use httpmock::prelude::*;
137
138 use super::*;
139
140 fn client(server: &MockServer) -> GithubClient {
141 GithubClient::with_base_url("owner/repo", "tkn", server.base_url())
142 }
143
144 #[test]
145 fn creates_comment_when_marker_absent() {
146 let server = MockServer::start();
147 server.mock(|when, then| {
148 when.method(GET)
149 .path("/repos/owner/repo/issues/5/comments")
150 .header("authorization", "Bearer tkn");
151 then.status(200).json_body(serde_json::json!([
152 { "id": 1, "body": "unrelated" }
153 ]));
154 });
155 let post = server.mock(|when, then| {
156 when.method(POST)
157 .path("/repos/owner/repo/issues/5/comments")
158 .body_contains("badgers-report");
159 then.status(201).json_body(serde_json::json!({ "id": 2 }));
160 });
161
162 let action = client(&server)
163 .upsert_pr_comment(
164 5,
165 "<!-- badgers-report:owner/repo:5 -->",
166 "<!-- badgers-report:owner/repo:5 -->\nhello",
167 )
168 .unwrap();
169 assert_eq!(action, CommentAction::Created);
170 post.assert();
171 }
172
173 #[test]
174 fn updates_existing_marked_comment() {
175 let server = MockServer::start();
176 server.mock(|when, then| {
177 when.method(GET).path("/repos/owner/repo/issues/5/comments");
178 then.status(200).json_body(serde_json::json!([
179 { "id": 7, "body": "<!-- badgers-report:owner/repo:5 -->\nold" }
180 ]));
181 });
182 let patch = server.mock(|when, then| {
183 when.method(httpmock::Method::PATCH)
184 .path("/repos/owner/repo/issues/comments/7")
185 .body_contains("new body");
186 then.status(200).json_body(serde_json::json!({ "id": 7 }));
187 });
188
189 let action = client(&server)
190 .upsert_pr_comment(5, "<!-- badgers-report:owner/repo:5 -->", "new body")
191 .unwrap();
192 assert_eq!(action, CommentAction::Updated);
193 patch.assert();
194 }
195
196 #[test]
197 fn surfaces_permission_errors_with_status() {
198 let server = MockServer::start();
199 server.mock(|when, then| {
200 when.method(GET).path("/repos/owner/repo/issues/5/comments");
201 then.status(403)
202 .body("Resource not accessible by integration");
203 });
204
205 let err = client(&server).upsert_pr_comment(5, "m", "b").unwrap_err();
206 assert!(matches!(
207 err,
208 GithubError::UnexpectedStatus { status: 403, .. }
209 ));
210 }
211}