1use super::{GithubApiClient, serde_structs::ThreadComment};
4use crate::{
5 AnnotationLevel, CommentKind, CommentPolicy, FileAnnotation, RestApiClient,
6 RestApiRateLimitHeaders, ThreadCommentOptions,
7 client::{ClientError, USER_AGENT, common::PullRequestEventPayload},
8};
9use reqwest::{
10 Client, Method, Url,
11 header::{AUTHORIZATION, HeaderMap, HeaderValue},
12};
13use std::{collections::HashMap, env, fs};
14
15impl GithubApiClient {
16 pub fn new() -> Result<Self, ClientError> {
18 let event_name = env::var("GITHUB_EVENT_NAME").unwrap_or(String::from("unknown"));
19 let pull_request = {
20 match event_name.as_str() {
21 "pull_request" => {
22 let event_payload_path = env::var("GITHUB_EVENT_PATH")
24 .map_err(|e| ClientError::env_var("GITHUB_EVENT_PATH", e))?;
25 let file_buf = fs::read_to_string(event_payload_path.clone()).map_err(|e| {
27 ClientError::io(
28 format!("read event payload from {event_payload_path}").as_str(),
29 e,
30 )
31 })?;
32 Some(
33 serde_json::from_str::<PullRequestEventPayload>(&file_buf)
34 .map_err(|e| ClientError::json("deserialize Event Payload", e))?
35 .pull_request,
36 )
37 }
38 _ => None,
39 }
40 };
41 let gh_api_url = env::var("GITHUB_API_URL").unwrap_or("https://api.github.com".to_string());
43 let api_url = Url::parse(format!("{}/", gh_api_url.trim_end_matches('/')).as_str())?;
44 let graphql_url = Url::parse(
45 env::var("GITHUB_GRAPHQL_URL")
46 .unwrap_or("https://api.github.com/graphql".to_string())
47 .as_str(),
48 )?;
49
50 Ok(Self {
51 client: Client::builder()
52 .default_headers(Self::make_headers()?)
53 .user_agent(USER_AGENT)
54 .build()?,
55 pull_request,
56 event_name,
57 api_url,
58 graphql_url,
59 repo: env::var("GITHUB_REPOSITORY")
60 .map_err(|e| ClientError::env_var("GITHUB_REPOSITORY", e))?,
61 sha: env::var("GITHUB_SHA").map_err(|e| ClientError::env_var("GITHUB_SHA", e))?,
62 debug_enabled: env::var("ACTIONS_STEP_DEBUG").is_ok_and(|val| &val == "true"),
63 rate_limit_headers: RestApiRateLimitHeaders {
64 reset: "x-ratelimit-reset".to_string(),
65 remaining: "x-ratelimit-remaining".to_string(),
66 retry: "retry-after".to_string(),
67 },
68 })
69 }
70
71 pub(super) fn make_headers() -> Result<HeaderMap<HeaderValue>, ClientError> {
72 let mut headers = HeaderMap::new();
73 headers.insert(
74 "Accept",
75 HeaderValue::from_str("application/vnd.github.raw+json")?,
76 );
77 if let Ok(token) = env::var("GITHUB_TOKEN") {
78 log::debug!("Using auth token from GITHUB_TOKEN environment variable");
79 let mut val = HeaderValue::from_str(format!("token {token}").as_str())?;
80 val.set_sensitive(true);
81 headers.insert(AUTHORIZATION, val);
82 } else {
83 log::warn!(
84 "No GITHUB_TOKEN environment variable found! Permission to post comments may be unsatisfied."
85 );
86 }
87 Ok(headers)
88 }
89
90 pub async fn update_comment(
92 &self,
93 url: Url,
94 options: ThreadCommentOptions,
95 ) -> Result<(), ClientError> {
96 let is_lgtm = options.kind == CommentKind::Lgtm;
97 let comment_url = self
98 .remove_bot_comments(
99 &url,
100 &options.marker,
101 (options.policy == CommentPolicy::Anew) || (is_lgtm && options.no_lgtm),
102 )
103 .await?;
104 let payload = HashMap::from([("body", options.mark_comment())]);
105
106 if !is_lgtm || !options.no_lgtm {
107 let req_meth = if comment_url.is_some() {
109 Method::PATCH
110 } else {
111 Method::POST
112 };
113 let request = self.make_api_request(
114 &self.client,
115 comment_url.unwrap_or(url),
116 req_meth,
117 Some(serde_json::json!(&payload).to_string()),
118 None,
119 )?;
120 match self
121 .send_api_request(&self.client, request, &self.rate_limit_headers)
122 .await
123 {
124 Ok(response) => {
125 self.log_response(response, "Failed to post thread comment")
126 .await;
127 }
128 Err(e) => {
129 return Err(e.add_request_context("post thread comment"));
130 }
131 }
132 }
133 Ok(())
134 }
135
136 async fn remove_bot_comments(
138 &self,
139 url: &Url,
140 comment_marker: &str,
141 delete: bool,
142 ) -> Result<Option<Url>, ClientError> {
143 let mut comment_url = None;
144 let mut comments_url = Some(Url::parse_with_params(url.as_str(), &[("page", "1")])?);
145 let repo = format!(
146 "repos/{}{}/comments",
147 self.repo,
149 if self.is_pr_event() { "/issues" } else { "" },
150 );
151 let base_comment_url = self.api_url.join(&repo)?;
152 while let Some(endpoint) = comments_url.take() {
153 let request = self.make_api_request(&self.client, endpoint, Method::GET, None, None)?;
154 let result = self
155 .send_api_request(&self.client, request, &self.rate_limit_headers)
156 .await;
157 match result {
158 Err(e) => {
159 return Err(e.add_request_context("get list of existing thread comments"));
160 }
161 Ok(response) => {
162 if !response.status().is_success() {
163 self.log_response(
164 response,
165 "Failed to get list of existing thread comments",
166 )
167 .await;
168 return Ok(comment_url);
169 }
170 comments_url = self.try_next_page(response.headers());
171 let payload =
172 serde_json::from_str::<Vec<ThreadComment>>(&response.text().await?)
173 .map_err(|e| {
174 ClientError::json("deserialize list of existing thread comments", e)
175 })?;
176 for comment in payload {
177 if comment.body.starts_with(comment_marker) {
178 log::debug!(
179 "Found bot comment id {} from user {} ({})",
180 comment.id,
181 comment.user.login,
182 comment.user.id,
183 );
184 let this_comment_url =
185 Url::parse(format!("{base_comment_url}/{}", comment.id).as_str())?;
186 if delete || comment_url.is_some() {
187 let del_url = if let Some(last_url) = &comment_url {
192 last_url
193 } else {
194 &this_comment_url
195 };
196 let req = self.make_api_request(
197 &self.client,
198 del_url.to_owned(),
199 Method::DELETE,
200 None,
201 None,
202 )?;
203 let result = self
204 .send_api_request(&self.client, req, &self.rate_limit_headers)
205 .await
206 .map_err(|e| {
207 e.add_request_context("delete old thread comment")
208 })?;
209 self.log_response(result, "Failed to delete old thread comment")
210 .await;
211 }
212 if !delete {
213 comment_url = Some(this_comment_url)
214 }
215 }
216 }
217 }
218 }
219 }
220 Ok(comment_url)
221 }
222}
223
224impl FileAnnotation {
225 pub fn fmt_github(&self) -> String {
234 let mut annotation_str = format!(
235 "::{}",
236 match self.severity {
237 AnnotationLevel::Debug => "debug",
238 AnnotationLevel::Notice => "notice",
239 AnnotationLevel::Warning => "warning",
240 AnnotationLevel::Error => "error",
241 }
242 );
243 let file_path = self
244 .path
245 .replace("\\", "/")
246 .trim_start()
247 .trim_start_matches('/')
248 .trim_start_matches("./")
249 .trim()
250 .to_string();
251 if !file_path.is_empty() {
252 annotation_str.push_str(" file=");
253 annotation_str.push_str(file_path.as_str());
254 if let Some(start_line) = self.start_line.map(|l| l.max(1)) {
255 annotation_str.push_str(format!(",line={start_line}").as_str());
256 let col = self.start_column.map(|c| c.max(1));
257 if let Some(col) = col {
258 annotation_str.push_str(format!(",col={col}").as_str());
259 }
260 if let Some(end_line) = self.end_line.map(|l| l.max(1))
261 && end_line > start_line
262 {
263 annotation_str.push_str(format!(",endline={end_line}").as_str());
264 if let Some(end_col) = self.end_column.map(|c| c.max(1)) {
265 annotation_str.push_str(format!(",endColumn={end_col}").as_str());
266 }
267 } else if let Some(end_col) = self.end_column.map(|c| c.max(1))
268 && col.is_none_or(|c| c < end_col)
269 {
270 annotation_str.push_str(format!(",endColumn={end_col}").as_str());
271 }
272 }
273 if let Some(title) = &self.title {
274 annotation_str.push_str(",title=");
275 annotation_str.push_str(title.as_str());
276 }
277 } else if let Some(title) = &self.title {
278 annotation_str.push_str(" title=");
279 annotation_str.push_str(title.as_str());
280 }
281 format!("{annotation_str}::{}", self.message)
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 #![allow(clippy::unwrap_used)]
288
289 use std::env;
290
291 use super::GithubApiClient;
292 use crate::{AnnotationLevel, FileAnnotation};
293
294 #[test]
295 fn generic_message() {
296 let annotation = FileAnnotation {
297 severity: AnnotationLevel::Debug,
298 message: "This is a debug message".to_string(),
299 ..Default::default()
300 };
301 assert_eq!(annotation.fmt_github(), "::debug::This is a debug message");
302 }
303
304 #[test]
305 fn annotate_file() {
306 let annotation = FileAnnotation {
307 severity: AnnotationLevel::Warning,
308 message: "This is a warning message".to_string(),
309 path: "/.\\src\\main.rs".to_string(),
310 title: Some("Warning Title".to_string()),
311 ..Default::default()
312 };
313 assert_eq!(
314 annotation.fmt_github(),
315 "::warning file=src/main.rs,title=Warning Title::This is a warning message"
316 );
317 }
318
319 #[test]
320 fn annotate_file_with_start_line() {
321 let annotation = FileAnnotation {
322 severity: AnnotationLevel::Error,
323 path: "src/lib.rs".to_string(),
324 message: "This is an error message".to_string(),
325 start_line: Some(10),
326 ..Default::default()
327 };
328 assert_eq!(
329 annotation.fmt_github(),
330 "::error file=src/lib.rs,line=10::This is an error message"
331 );
332 }
333
334 #[test]
335 fn annotate_file_with_start_line_col() {
336 let annotation = FileAnnotation {
337 severity: AnnotationLevel::Error,
338 path: "src/lib.rs".to_string(),
339 message: "This is an error message".to_string(),
340 start_line: Some(10),
341 start_column: Some(5),
342 ..Default::default()
343 };
344 assert_eq!(
345 annotation.fmt_github(),
346 "::error file=src/lib.rs,line=10,col=5::This is an error message"
347 );
348 }
349
350 #[test]
351 fn annotate_file_with_line_span() {
352 let annotation = FileAnnotation {
353 severity: AnnotationLevel::Notice,
354 path: "src/lib.rs".to_string(),
355 message: "This is a notice message".to_string(),
356 start_line: Some(10),
357 end_line: Some(20),
358 ..Default::default()
359 };
360 assert_eq!(
361 annotation.fmt_github(),
362 "::notice file=src/lib.rs,line=10,endline=20::This is a notice message"
363 );
364 }
365
366 #[test]
367 fn annotate_file_with_line_col_span() {
368 let annotation = FileAnnotation {
369 severity: AnnotationLevel::Notice,
370 path: "src/lib.rs".to_string(),
371 message: "This is a notice message".to_string(),
372 start_line: Some(10),
373 start_column: Some(5),
374 end_line: Some(20),
375 end_column: Some(15),
376 ..Default::default()
377 };
378 assert_eq!(
379 annotation.fmt_github(),
380 "::notice file=src/lib.rs,line=10,col=5,endline=20,endColumn=15::This is a notice message"
381 );
382 }
383
384 #[test]
385 fn annotate_file_with_col_span_on_1_line() {
386 let annotation = FileAnnotation {
387 severity: AnnotationLevel::Notice,
388 path: "src/lib.rs".to_string(),
389 message: "This is a notice message".to_string(),
390 start_line: Some(10),
391 end_line: Some(2),
392 start_column: Some(5),
393 end_column: Some(15),
394 ..Default::default()
395 };
396 assert_eq!(
397 annotation.fmt_github(),
398 "::notice file=src/lib.rs,line=10,col=5,endColumn=15::This is a notice message"
399 );
400 }
401
402 #[test]
403 fn annotate_blank_path_with_title() {
404 let annotation = FileAnnotation {
405 severity: AnnotationLevel::Debug,
406 message: "This is a debug message".to_string(),
407 title: Some("Debug Title".to_string()),
408 start_line: Some(10),
409 ..Default::default()
410 };
411 assert_eq!(
412 annotation.fmt_github(),
413 "::debug title=Debug Title::This is a debug message"
414 );
415 }
416
417 #[test]
418 fn annotate_blank_path_no_title() {
419 let annotation = FileAnnotation {
420 severity: AnnotationLevel::Debug,
421 message: "This is a debug message".to_string(),
422 start_line: Some(10),
423 ..Default::default()
424 };
425 assert_eq!(annotation.fmt_github(), "::debug::This is a debug message");
426 }
427
428 #[test]
429 fn ensure_api_url_has_trailing_slash() {
430 let gh_api_url = "https://api.github.com/v3";
431 unsafe {
432 env::set_var("GITHUB_REPOSITORY", "2bndy5/git-bot-feedback");
433 env::set_var("GITHUB_SHA", "abc123");
434 env::set_var("GITHUB_EVENT_NAME", "push");
435 env::set_var("GITHUB_API_URL", format!("{gh_api_url}//"));
437 env::set_var("GITHUB_GRAPHQL_URL", "https://ghe.example.com/api/graphql");
438 }
439 let client = GithubApiClient::new().unwrap();
440 assert_eq!(
441 client.graphql_url.as_str(),
442 "https://ghe.example.com/api/graphql"
443 );
444 assert!(client.api_url.as_str().ends_with("/v3/"));
445 let endpoint = client.api_url.join("repos/").unwrap();
446 assert_eq!(endpoint.as_str(), "https://api.github.com/v3/repos/");
447 }
448}