1use 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, 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 FileAnnotation {
223 pub fn fmt_github(&self) -> String {
232 let mut annotation_str = format!(
233 "::{}",
234 match self.severity {
235 AnnotationLevel::Debug => "debug",
236 AnnotationLevel::Notice => "notice",
237 AnnotationLevel::Warning => "warning",
238 AnnotationLevel::Error => "error",
239 }
240 );
241 let file_path = self
242 .path
243 .replace("\\", "/")
244 .trim_start()
245 .trim_start_matches('/')
246 .trim_start_matches("./")
247 .trim()
248 .to_string();
249 if !file_path.is_empty() {
250 annotation_str.push_str(" file=");
251 annotation_str.push_str(file_path.as_str());
252 if let Some(start_line) = self.start_line.map(|l| l.max(1)) {
253 annotation_str.push_str(format!(",line={start_line}").as_str());
254 let col = self.start_column.map(|c| c.max(1));
255 if let Some(col) = col {
256 annotation_str.push_str(format!(",col={col}").as_str());
257 }
258 if let Some(end_line) = self.end_line.map(|l| l.max(1))
259 && end_line > start_line
260 {
261 annotation_str.push_str(format!(",endline={end_line}").as_str());
262 if let Some(end_col) = self.end_column.map(|c| c.max(1)) {
263 annotation_str.push_str(format!(",endColumn={end_col}").as_str());
264 }
265 } else if let Some(end_col) = self.end_column.map(|c| c.max(1))
266 && col.is_none_or(|c| c < end_col)
267 {
268 annotation_str.push_str(format!(",endColumn={end_col}").as_str());
269 }
270 }
271 if let Some(title) = &self.title {
272 annotation_str.push_str(",title=");
273 annotation_str.push_str(title.as_str());
274 }
275 } else if let Some(title) = &self.title {
276 annotation_str.push_str(" title=");
277 annotation_str.push_str(title.as_str());
278 }
279 format!("{annotation_str}::{}", self.message)
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use crate::{AnnotationLevel, FileAnnotation};
286
287 #[test]
288 fn generic_message() {
289 let annotation = FileAnnotation {
290 severity: AnnotationLevel::Debug,
291 message: "This is a debug message".to_string(),
292 ..Default::default()
293 };
294 assert_eq!(annotation.fmt_github(), "::debug::This is a debug message");
295 }
296
297 #[test]
298 fn annotate_file() {
299 let annotation = FileAnnotation {
300 severity: AnnotationLevel::Warning,
301 message: "This is a warning message".to_string(),
302 path: "/.\\src\\main.rs".to_string(),
303 title: Some("Warning Title".to_string()),
304 ..Default::default()
305 };
306 assert_eq!(
307 annotation.fmt_github(),
308 "::warning file=src/main.rs,title=Warning Title::This is a warning message"
309 );
310 }
311
312 #[test]
313 fn annotate_file_with_start_line() {
314 let annotation = FileAnnotation {
315 severity: AnnotationLevel::Error,
316 path: "src/lib.rs".to_string(),
317 message: "This is an error message".to_string(),
318 start_line: Some(10),
319 ..Default::default()
320 };
321 assert_eq!(
322 annotation.fmt_github(),
323 "::error file=src/lib.rs,line=10::This is an error message"
324 );
325 }
326
327 #[test]
328 fn annotate_file_with_start_line_col() {
329 let annotation = FileAnnotation {
330 severity: AnnotationLevel::Error,
331 path: "src/lib.rs".to_string(),
332 message: "This is an error message".to_string(),
333 start_line: Some(10),
334 start_column: Some(5),
335 ..Default::default()
336 };
337 assert_eq!(
338 annotation.fmt_github(),
339 "::error file=src/lib.rs,line=10,col=5::This is an error message"
340 );
341 }
342
343 #[test]
344 fn annotate_file_with_line_span() {
345 let annotation = FileAnnotation {
346 severity: AnnotationLevel::Notice,
347 path: "src/lib.rs".to_string(),
348 message: "This is a notice message".to_string(),
349 start_line: Some(10),
350 end_line: Some(20),
351 ..Default::default()
352 };
353 assert_eq!(
354 annotation.fmt_github(),
355 "::notice file=src/lib.rs,line=10,endline=20::This is a notice message"
356 );
357 }
358
359 #[test]
360 fn annotate_file_with_line_col_span() {
361 let annotation = FileAnnotation {
362 severity: AnnotationLevel::Notice,
363 path: "src/lib.rs".to_string(),
364 message: "This is a notice message".to_string(),
365 start_line: Some(10),
366 start_column: Some(5),
367 end_line: Some(20),
368 end_column: Some(15),
369 ..Default::default()
370 };
371 assert_eq!(
372 annotation.fmt_github(),
373 "::notice file=src/lib.rs,line=10,col=5,endline=20,endColumn=15::This is a notice message"
374 );
375 }
376
377 #[test]
378 fn annotate_file_with_col_span_on_1_line() {
379 let annotation = FileAnnotation {
380 severity: AnnotationLevel::Notice,
381 path: "src/lib.rs".to_string(),
382 message: "This is a notice message".to_string(),
383 start_line: Some(10),
384 end_line: Some(2),
385 start_column: Some(5),
386 end_column: Some(15),
387 ..Default::default()
388 };
389 assert_eq!(
390 annotation.fmt_github(),
391 "::notice file=src/lib.rs,line=10,col=5,endColumn=15::This is a notice message"
392 );
393 }
394
395 #[test]
396 fn annotate_blank_path_with_title() {
397 let annotation = FileAnnotation {
398 severity: AnnotationLevel::Debug,
399 message: "This is a debug message".to_string(),
400 title: Some("Debug Title".to_string()),
401 start_line: Some(10),
402 ..Default::default()
403 };
404 assert_eq!(
405 annotation.fmt_github(),
406 "::debug title=Debug Title::This is a debug message"
407 );
408 }
409
410 #[test]
411 fn annotate_blank_path_no_title() {
412 let annotation = FileAnnotation {
413 severity: AnnotationLevel::Debug,
414 message: "This is a debug message".to_string(),
415 start_line: Some(10),
416 ..Default::default()
417 };
418 assert_eq!(annotation.fmt_github(), "::debug::This is a debug message");
419 }
420}