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