git_bot_feedback/client/github/
specific_api.rs1use super::{
4 GithubApiClient,
5 serde_structs::{PullRequestEventPayload, ThreadComment},
6};
7use crate::{
8 AnnotationLevel, CommentKind, CommentPolicy, FileAnnotation, RestApiClient,
9 RestApiRateLimitHeaders, ThreadCommentOptions,
10 client::{ClientError, USER_AGENT},
11};
12use reqwest::{
13 Client, Method, Url,
14 header::{AUTHORIZATION, HeaderMap, HeaderValue},
15};
16use std::{collections::HashMap, env, fmt::Display, fs};
17
18impl GithubApiClient {
19 pub fn new() -> Result<Self, ClientError> {
21 let event_name = env::var("GITHUB_EVENT_NAME").unwrap_or(String::from("unknown"));
22 let pull_request = {
23 match event_name.as_str() {
24 "pull_request" => {
25 let event_payload_path = env::var("GITHUB_EVENT_PATH")
27 .map_err(|e| ClientError::env_var("GITHUB_EVENT_PATH", e))?;
28 let file_buf = fs::read_to_string(event_payload_path.clone()).map_err(|e| {
30 ClientError::io(
31 format!("read event payload from {event_payload_path}").as_str(),
32 e,
33 )
34 })?;
35 Some(
36 serde_json::from_str::<PullRequestEventPayload>(&file_buf)
37 .map_err(|e| ClientError::json("deserialize Event Payload", e))?
38 .pull_request,
39 )
40 }
41 _ => None,
42 }
43 };
44 let gh_api_url = env::var("GITHUB_API_URL").unwrap_or("https://api.github.com".to_string());
46 let api_url = Url::parse(gh_api_url.as_str())?;
47
48 Ok(GithubApiClient {
49 client: Client::builder()
50 .default_headers(Self::make_headers()?)
51 .user_agent(USER_AGENT)
52 .build()?,
53 pull_request,
54 event_name,
55 api_url,
56 repo: env::var("GITHUB_REPOSITORY")
57 .map_err(|e| ClientError::env_var("GITHUB_REPOSITORY", e))?,
58 sha: env::var("GITHUB_SHA").map_err(|e| ClientError::env_var("GITHUB_SHA", e))?,
59 debug_enabled: env::var("ACTIONS_STEP_DEBUG").is_ok_and(|val| &val == "true"),
60 rate_limit_headers: RestApiRateLimitHeaders {
61 reset: "x-ratelimit-reset".to_string(),
62 remaining: "x-ratelimit-remaining".to_string(),
63 retry: "retry-after".to_string(),
64 },
65 })
66 }
67
68 pub(super) fn make_headers() -> Result<HeaderMap<HeaderValue>, ClientError> {
69 let mut headers = HeaderMap::new();
70 headers.insert(
71 "Accept",
72 HeaderValue::from_str("application/vnd.github.raw+json")?,
73 );
74 if let Ok(token) = env::var("GITHUB_TOKEN") {
75 log::debug!("Using auth token from GITHUB_TOKEN environment variable");
76 let mut val = HeaderValue::from_str(format!("token {token}").as_str())?;
77 val.set_sensitive(true);
78 headers.insert(AUTHORIZATION, val);
79 } else {
80 log::warn!(
81 "No GITHUB_TOKEN environment variable found! Permission to post comments may be unsatisfied."
82 );
83 }
84 Ok(headers)
85 }
86
87 pub async fn update_comment(
89 &self,
90 url: Url,
91 options: ThreadCommentOptions,
92 ) -> Result<(), ClientError> {
93 let is_lgtm = options.kind == CommentKind::Lgtm;
94 let comment_url = self
95 .remove_bot_comments(
96 &url,
97 &options.marker,
98 (options.policy == CommentPolicy::Anew) || (is_lgtm && options.no_lgtm),
99 )
100 .await?;
101 let payload = HashMap::from([("body", options.mark_comment())]);
102
103 if !is_lgtm || !options.no_lgtm {
104 let req_meth = if comment_url.is_some() {
106 Method::PATCH
107 } else {
108 Method::POST
109 };
110 let request = self.make_api_request(
111 &self.client,
112 comment_url.unwrap_or(url),
113 req_meth,
114 Some(serde_json::json!(&payload).to_string()),
115 None,
116 )?;
117 match self
118 .send_api_request(&self.client, request, &self.rate_limit_headers)
119 .await
120 {
121 Ok(response) => {
122 self.log_response(response, "Failed to post thread comment")
123 .await;
124 }
125 Err(e) => {
126 return Err(e.add_request_context("post thread comment"));
127 }
128 }
129 }
130 Ok(())
131 }
132
133 async fn remove_bot_comments(
135 &self,
136 url: &Url,
137 comment_marker: &str,
138 delete: bool,
139 ) -> Result<Option<Url>, ClientError> {
140 let mut comment_url = None;
141 let mut comments_url = Some(Url::parse_with_params(url.as_str(), &[("page", "1")])?);
142 let repo = format!(
143 "repos/{}{}/comments",
144 self.repo,
146 if self.is_pr_event() { "/issues" } else { "" },
147 );
148 let base_comment_url = self.api_url.join(&repo)?;
149 while let Some(ref endpoint) = comments_url {
150 let request =
151 self.make_api_request(&self.client, endpoint.to_owned(), Method::GET, None, None)?;
152 let result = self
153 .send_api_request(&self.client, request, &self.rate_limit_headers)
154 .await;
155 match result {
156 Err(e) => {
157 return Err(e.add_request_context("get list of existing thread comments"));
158 }
159 Ok(response) => {
160 if !response.status().is_success() {
161 self.log_response(
162 response,
163 "Failed to get list of existing thread comments",
164 )
165 .await;
166 return Ok(comment_url);
167 }
168 comments_url = self.try_next_page(response.headers());
169 let payload =
170 serde_json::from_str::<Vec<ThreadComment>>(&response.text().await?)
171 .map_err(|e| {
172 ClientError::json("deserialize list of existing thread comments", e)
173 })?;
174 for comment in payload {
175 if comment.body.starts_with(comment_marker) {
176 log::debug!(
177 "Found bot comment id {} from user {} ({})",
178 comment.id,
179 comment.user.login,
180 comment.user.id,
181 );
182 let this_comment_url =
183 Url::parse(format!("{base_comment_url}/{}", comment.id).as_str())?;
184 if delete || comment_url.is_some() {
185 let del_url = if let Some(last_url) = &comment_url {
190 last_url
191 } else {
192 &this_comment_url
193 };
194 let req = self.make_api_request(
195 &self.client,
196 del_url.to_owned(),
197 Method::DELETE,
198 None,
199 None,
200 )?;
201 let result = self
202 .send_api_request(&self.client, req, &self.rate_limit_headers)
203 .await
204 .map_err(|e| {
205 e.add_request_context("delete old thread comment")
206 })?;
207 self.log_response(result, "Failed to delete old thread comment")
208 .await;
209 }
210 if !delete {
211 comment_url = Some(this_comment_url)
212 }
213 }
214 }
215 }
216 }
217 }
218 Ok(comment_url)
219 }
220}
221
222impl Display for FileAnnotation {
223 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229 let mut annotation_str = format!(
230 "::{}",
231 match self.severity {
232 AnnotationLevel::Debug => "debug",
233 AnnotationLevel::Notice => "notice",
234 AnnotationLevel::Warning => "warning",
235 AnnotationLevel::Error => "error",
236 }
237 );
238 if !self.path.is_empty() {
239 annotation_str.push_str(" file=");
240 annotation_str.push_str(self.path.as_str());
241 if let Some(start_line) = self.start_line {
242 annotation_str.push_str(format!(",line={start_line}").as_str());
243 let col = self.start_column.map(|c| c.max(1));
244 if let Some(col) = col {
245 annotation_str.push_str(format!(",col={col}").as_str());
246 }
247 if let Some(end_line) = self.end_line.map(|l| l.max(1))
248 && end_line > start_line
249 {
250 annotation_str.push_str(format!(",endline={end_line}").as_str());
251 if let Some(end_col) = self.end_column.map(|c| c.max(1))
252 && col.is_none_or(|c| c < end_col)
253 {
254 annotation_str.push_str(format!(",endColumn={end_col}").as_str());
255 }
256 }
257 }
258 }
259 if let Some(title) = &self.title {
260 annotation_str.push_str(",title=");
261 annotation_str.push_str(title.as_str());
262 }
263 write!(f, "{}::{}", annotation_str, self.message)
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use crate::{AnnotationLevel, FileAnnotation};
270
271 #[test]
272 fn generic_message() {
273 let annotation = FileAnnotation {
274 severity: AnnotationLevel::Debug,
275 message: "This is a debug message".to_string(),
276 ..Default::default()
277 };
278 assert_eq!(annotation.to_string(), "::debug::This is a debug message");
279 }
280
281 #[test]
282 fn annotate_file() {
283 let annotation = FileAnnotation {
284 severity: AnnotationLevel::Warning,
285 message: "This is a warning message".to_string(),
286 path: "src/main.rs".to_string(),
287 title: Some("Warning Title".to_string()),
288 ..Default::default()
289 };
290 assert_eq!(
291 annotation.to_string(),
292 "::warning file=src/main.rs,title=Warning Title::This is a warning message"
293 );
294 }
295
296 #[test]
297 fn annotate_file_with_start_line() {
298 let annotation = FileAnnotation {
299 severity: AnnotationLevel::Error,
300 path: "src/lib.rs".to_string(),
301 message: "This is an error message".to_string(),
302 start_line: Some(10),
303 ..Default::default()
304 };
305 assert_eq!(
306 annotation.to_string(),
307 "::error file=src/lib.rs,line=10::This is an error message"
308 );
309 }
310
311 #[test]
312 fn annotate_file_with_start_line_col() {
313 let annotation = FileAnnotation {
314 severity: AnnotationLevel::Error,
315 path: "src/lib.rs".to_string(),
316 message: "This is an error message".to_string(),
317 start_line: Some(10),
318 start_column: Some(5),
319 ..Default::default()
320 };
321 assert_eq!(
322 annotation.to_string(),
323 "::error file=src/lib.rs,line=10,col=5::This is an error message"
324 );
325 }
326
327 #[test]
328 fn annotate_file_with_line_span() {
329 let annotation = FileAnnotation {
330 severity: AnnotationLevel::Notice,
331 path: "src/lib.rs".to_string(),
332 message: "This is a notice message".to_string(),
333 start_line: Some(10),
334 end_line: Some(20),
335 ..Default::default()
336 };
337 assert_eq!(
338 annotation.to_string(),
339 "::notice file=src/lib.rs,line=10,endline=20::This is a notice message"
340 );
341 }
342 #[test]
343 fn annotate_file_with_line_col_span() {
344 let annotation = FileAnnotation {
345 severity: AnnotationLevel::Notice,
346 path: "src/lib.rs".to_string(),
347 message: "This is a notice message".to_string(),
348 start_line: Some(10),
349 start_column: Some(5),
350 end_line: Some(20),
351 end_column: Some(15),
352 ..Default::default()
353 };
354 assert_eq!(
355 annotation.to_string(),
356 "::notice file=src/lib.rs,line=10,col=5,endline=20,endColumn=15::This is a notice message"
357 );
358 }
359}