1use serde::{Deserialize, Serialize};
4use std::time::Duration;
5
6#[derive(Debug, thiserror::Error)]
7pub enum GithubError {
8 #[error("github http error: {0}")]
9 Http(#[from] reqwest::Error),
10 #[error("github api returned {status}: {body}")]
11 UnexpectedStatus { status: u16, body: String },
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum CommentAction {
16 Created,
17 Updated,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21pub struct CheckAnnotation {
22 pub path: String,
23 pub start_line: u32,
24 pub end_line: u32,
25 pub annotation_level: &'static str,
26 pub message: String,
27 pub title: String,
28}
29
30impl CheckAnnotation {
31 pub fn warning(path: impl Into<String>, start_line: u32, end_line: u32) -> Self {
32 let (lines, verb) = if start_line == end_line {
33 (format!("line {start_line}"), "is")
34 } else {
35 (format!("lines {start_line}-{end_line}"), "are")
36 };
37 Self {
38 path: path.into(),
39 start_line,
40 end_line,
41 annotation_level: "warning",
42 message: format!("Changed executable {lines} {verb} not covered."),
43 title: "Uncovered changed lines".to_string(),
44 }
45 }
46}
47
48pub struct GithubClient {
49 client: reqwest::blocking::Client,
50 base_url: String,
51 repo: String,
52 token: String,
53}
54
55#[derive(Deserialize)]
56struct IssueComment {
57 id: u64,
58 body: Option<String>,
59 user: Option<IssueCommentUser>,
60 performed_via_github_app: Option<IssueCommentApp>,
61}
62
63#[derive(Deserialize)]
64struct IssueCommentUser {
65 #[serde(rename = "type")]
66 kind: String,
67}
68
69#[derive(Deserialize)]
70struct IssueCommentApp {
71 slug: String,
72}
73
74#[derive(Default)]
75struct CommentMatches {
76 canonical_ids: Vec<u64>,
77 legacy_ids: Vec<u64>,
78}
79
80#[derive(Deserialize)]
81struct CheckRun {
82 id: u64,
83}
84
85#[derive(Deserialize)]
86struct PullRequest {
87 head: PullRequestHead,
88}
89
90#[derive(Deserialize)]
91struct PullRequestHead {
92 sha: String,
93}
94
95const PER_PAGE: u32 = 100;
96const MAX_PAGES: u32 = 10;
97const ANNOTATIONS_PER_REQUEST: usize = 50;
98
99impl GithubClient {
100 pub fn new(repo: impl Into<String>, token: impl Into<String>) -> Self {
101 Self::with_base_url(repo, token, "https://api.github.com")
102 }
103
104 pub fn with_base_url(
105 repo: impl Into<String>,
106 token: impl Into<String>,
107 base_url: impl Into<String>,
108 ) -> Self {
109 Self {
110 client: reqwest::blocking::Client::builder()
111 .connect_timeout(Duration::from_secs(10))
112 .timeout(Duration::from_secs(30))
113 .redirect(reqwest::redirect::Policy::none())
114 .build()
115 .expect("static GitHub HTTP client configuration is valid"),
116 base_url: base_url.into().trim_end_matches('/').to_string(),
117 repo: repo.into(),
118 token: token.into(),
119 }
120 }
121
122 pub fn upsert_pr_comment(
125 &self,
126 pr_number: u64,
127 marker: &str,
128 body: &str,
129 ) -> Result<CommentAction, GithubError> {
130 self.upsert_pr_comment_with_aliases(pr_number, marker, &[], body)
131 }
132
133 pub fn upsert_pr_comment_with_aliases(
134 &self,
135 pr_number: u64,
136 marker: &str,
137 legacy_marker_prefixes: &[&str],
138 body: &str,
139 ) -> Result<CommentAction, GithubError> {
140 let matches = self.find_comments(pr_number, marker, legacy_marker_prefixes)?;
141 let comment_id = matches
142 .canonical_ids
143 .iter()
144 .max()
145 .or_else(|| matches.legacy_ids.iter().max())
146 .copied();
147 let action = if let Some(comment_id) = comment_id {
148 let url = format!(
149 "{}/repos/{}/issues/comments/{comment_id}",
150 self.base_url, self.repo
151 );
152 let resp = self
153 .request(self.client.patch(&url))
154 .json(&serde_json::json!({ "body": body }))
155 .send()?;
156 expect_success(resp)?;
157 CommentAction::Updated
158 } else {
159 let url = format!(
160 "{}/repos/{}/issues/{pr_number}/comments",
161 self.base_url, self.repo
162 );
163 let resp = self
164 .request(self.client.post(&url))
165 .json(&serde_json::json!({ "body": body }))
166 .send()?;
167 expect_success(resp)?;
168 CommentAction::Created
169 };
170
171 for duplicate_id in matches
172 .canonical_ids
173 .iter()
174 .chain(&matches.legacy_ids)
175 .copied()
176 .filter(|id| Some(*id) != comment_id)
177 {
178 let url = format!(
179 "{}/repos/{}/issues/comments/{duplicate_id}",
180 self.base_url, self.repo
181 );
182 let response = self.request(self.client.delete(&url)).send()?;
183 expect_success(response)?;
184 }
185 Ok(action)
186 }
187
188 pub fn pull_request_head_sha(&self, pr_number: u64) -> Result<String, GithubError> {
189 let url = format!("{}/repos/{}/pulls/{pr_number}", self.base_url, self.repo);
190 let response = self.request(self.client.get(&url)).send()?;
191 let pull_request: PullRequest = expect_success(response)?.json()?;
192 Ok(pull_request.head.sha)
193 }
194
195 pub fn create_check_run(
196 &self,
197 name: &str,
198 head_sha: &str,
199 title: &str,
200 summary: &str,
201 annotations: &[CheckAnnotation],
202 ) -> Result<u64, GithubError> {
203 let first_batch = annotations
204 .iter()
205 .take(ANNOTATIONS_PER_REQUEST)
206 .collect::<Vec<_>>();
207 let url = format!("{}/repos/{}/check-runs", self.base_url, self.repo);
208 let resp = self
209 .request(self.client.post(&url))
210 .json(&serde_json::json!({
211 "name": name,
212 "head_sha": head_sha,
213 "status": "completed",
214 "conclusion": "success",
215 "output": {
216 "title": title,
217 "summary": summary,
218 "annotations": first_batch,
219 },
220 }))
221 .send()?;
222 let run: CheckRun = expect_success(resp)?.json()?;
223
224 for batch in annotations[annotations.len().min(ANNOTATIONS_PER_REQUEST)..]
225 .chunks(ANNOTATIONS_PER_REQUEST)
226 {
227 let url = format!(
228 "{}/repos/{}/check-runs/{}",
229 self.base_url, self.repo, run.id
230 );
231 let resp = self
232 .request(self.client.patch(&url))
233 .json(&serde_json::json!({
234 "output": {
235 "title": title,
236 "summary": summary,
237 "annotations": batch,
238 },
239 }))
240 .send()?;
241 expect_success(resp)?;
242 }
243 Ok(run.id)
244 }
245
246 fn find_comments(
247 &self,
248 pr_number: u64,
249 marker: &str,
250 legacy_marker_prefixes: &[&str],
251 ) -> Result<CommentMatches, GithubError> {
252 let mut matches = CommentMatches::default();
253 for page in 1..=MAX_PAGES {
254 let url = format!(
255 "{}/repos/{}/issues/{pr_number}/comments?per_page={PER_PAGE}&page={page}",
256 self.base_url, self.repo
257 );
258 let resp = self.request(self.client.get(&url)).send()?;
259 let resp = expect_success(resp)?;
260 let comments: Vec<IssueComment> = resp.json()?;
261 let count = comments.len();
262 for comment in comments {
263 if !comment.is_owned_by_github_actions() {
264 continue;
265 }
266 let Some(first_line) = comment.body.as_deref().and_then(|body| body.lines().next())
267 else {
268 continue;
269 };
270 if first_line == marker {
271 matches.canonical_ids.push(comment.id);
272 } else if legacy_marker_prefixes
273 .iter()
274 .any(|prefix| valid_legacy_marker(first_line, prefix))
275 {
276 matches.legacy_ids.push(comment.id);
277 }
278 }
279 if count < PER_PAGE as usize {
280 break;
281 }
282 }
283 Ok(matches)
284 }
285
286 fn request(
287 &self,
288 builder: reqwest::blocking::RequestBuilder,
289 ) -> reqwest::blocking::RequestBuilder {
290 builder
291 .bearer_auth(&self.token)
292 .header("accept", "application/vnd.github+json")
293 .header("x-github-api-version", "2022-11-28")
294 .header("user-agent", "badgers")
295 }
296}
297
298impl IssueComment {
299 fn is_owned_by_github_actions(&self) -> bool {
300 self.user.as_ref().is_some_and(|user| user.kind == "Bot")
301 && self
302 .performed_via_github_app
303 .as_ref()
304 .is_some_and(|app| app.slug == "github-actions")
305 }
306}
307
308fn valid_legacy_marker(line: &str, prefix: &str) -> bool {
309 line.strip_prefix(prefix)
310 .and_then(|suffix| suffix.strip_suffix(" -->"))
311 .is_some_and(|sha| sha.len() == 40 && sha.bytes().all(|byte| byte.is_ascii_hexdigit()))
312}
313
314fn expect_success(
315 resp: reqwest::blocking::Response,
316) -> Result<reqwest::blocking::Response, GithubError> {
317 let status = resp.status();
318 if status.is_success() {
319 Ok(resp)
320 } else {
321 Err(GithubError::UnexpectedStatus {
322 status: status.as_u16(),
323 body: resp.text().unwrap_or_default(),
324 })
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use httpmock::prelude::*;
331
332 use super::*;
333
334 fn client(server: &MockServer) -> GithubClient {
335 GithubClient::with_base_url("owner/repo", "tkn", server.base_url())
336 }
337
338 #[test]
339 fn creates_comment_when_marker_absent() {
340 let server = MockServer::start();
341 server.mock(|when, then| {
342 when.method(GET)
343 .path("/repos/owner/repo/issues/5/comments")
344 .header("authorization", "Bearer tkn");
345 then.status(200).json_body(serde_json::json!([
346 { "id": 1, "body": "unrelated" }
347 ]));
348 });
349 let post = server.mock(|when, then| {
350 when.method(POST)
351 .path("/repos/owner/repo/issues/5/comments")
352 .body_contains("badgers-report");
353 then.status(201).json_body(serde_json::json!({ "id": 2 }));
354 });
355
356 let action = client(&server)
357 .upsert_pr_comment(
358 5,
359 "<!-- badgers-report:owner/repo:5 -->",
360 "<!-- badgers-report:owner/repo:5 -->\nhello",
361 )
362 .unwrap();
363 assert_eq!(action, CommentAction::Created);
364 post.assert();
365 }
366
367 #[test]
368 fn updates_existing_marked_comment() {
369 let server = MockServer::start();
370 server.mock(|when, then| {
371 when.method(GET).path("/repos/owner/repo/issues/5/comments");
372 then.status(200).json_body(serde_json::json!([{
373 "id": 7,
374 "body": "<!-- badgers-report:owner/repo:5 -->\nold",
375 "user": { "type": "Bot" },
376 "performed_via_github_app": { "slug": "github-actions" }
377 }]));
378 });
379 let patch = server.mock(|when, then| {
380 when.method(httpmock::Method::PATCH)
381 .path("/repos/owner/repo/issues/comments/7")
382 .body_contains("new body");
383 then.status(200).json_body(serde_json::json!({ "id": 7 }));
384 });
385
386 let action = client(&server)
387 .upsert_pr_comment(5, "<!-- badgers-report:owner/repo:5 -->", "new body")
388 .unwrap();
389 assert_eq!(action, CommentAction::Updated);
390 patch.assert();
391 }
392
393 #[test]
394 fn surfaces_permission_errors_with_status() {
395 let server = MockServer::start();
396 server.mock(|when, then| {
397 when.method(GET).path("/repos/owner/repo/issues/5/comments");
398 then.status(403)
399 .body("Resource not accessible by integration");
400 });
401
402 let err = client(&server).upsert_pr_comment(5, "m", "b").unwrap_err();
403 assert!(matches!(
404 err,
405 GithubError::UnexpectedStatus { status: 403, .. }
406 ));
407 }
408
409 #[test]
410 fn updates_legacy_per_head_comment_during_migration() {
411 let server = MockServer::start();
412 server.mock(|when, then| {
413 when.method(GET).path("/repos/owner/repo/issues/5/comments");
414 then.status(200).json_body(serde_json::json!([{
415 "id": 8,
416 "body": "<!-- badgers-report:owner/repo:5:0123456789abcdef0123456789abcdef01234567 -->\nold",
417 "user": { "type": "Bot" },
418 "performed_via_github_app": { "slug": "github-actions" }
419 }]));
420 });
421 let patch = server.mock(|when, then| {
422 when.method(httpmock::Method::PATCH)
423 .path("/repos/owner/repo/issues/comments/8")
424 .body_contains("badgers-report:owner/repo:5 --");
425 then.status(200).json_body(serde_json::json!({ "id": 8 }));
426 });
427
428 let action = client(&server)
429 .upsert_pr_comment_with_aliases(
430 5,
431 "<!-- badgers-report:owner/repo:5 -->",
432 &["<!-- badgers-report:owner/repo:5:"],
433 "<!-- badgers-report:owner/repo:5 -->\nnew",
434 )
435 .unwrap();
436 assert_eq!(action, CommentAction::Updated);
437 patch.assert();
438 }
439
440 #[test]
441 fn prefers_canonical_comment_and_removes_owned_legacy_duplicates() {
442 let server = MockServer::start();
443 server.mock(|when, then| {
444 when.method(GET).path("/repos/owner/repo/issues/5/comments");
445 then.status(200).json_body(serde_json::json!([
446 {
447 "id": 7,
448 "body": "<!-- badgers-report:owner/repo:5:0123456789abcdef0123456789abcdef01234567 -->\nlegacy",
449 "user": { "type": "Bot" },
450 "performed_via_github_app": { "slug": "github-actions" }
451 },
452 {
453 "id": 8,
454 "body": "<!-- badgers-report:owner/repo:5 -->\ncanonical",
455 "user": { "type": "Bot" },
456 "performed_via_github_app": { "slug": "github-actions" }
457 },
458 {
459 "id": 9,
460 "body": "<!-- badgers-report:owner/repo:5:not-a-sha -->\nspoof",
461 "user": { "type": "Bot" },
462 "performed_via_github_app": { "slug": "github-actions" }
463 },
464 {
465 "id": 10,
466 "body": "<!-- badgers-report:owner/repo:5 -->\nuser spoof",
467 "user": { "type": "User" },
468 "performed_via_github_app": null
469 }
470 ]));
471 });
472 let patch = server.mock(|when, then| {
473 when.method(httpmock::Method::PATCH)
474 .path("/repos/owner/repo/issues/comments/8");
475 then.status(200).json_body(serde_json::json!({ "id": 8 }));
476 });
477 let delete = server.mock(|when, then| {
478 when.method(DELETE)
479 .path("/repos/owner/repo/issues/comments/7");
480 then.status(204);
481 });
482
483 let action = client(&server)
484 .upsert_pr_comment_with_aliases(
485 5,
486 "<!-- badgers-report:owner/repo:5 -->",
487 &["<!-- badgers-report:owner/repo:5:"],
488 "<!-- badgers-report:owner/repo:5 -->\nnew",
489 )
490 .unwrap();
491 assert_eq!(action, CommentAction::Updated);
492 patch.assert();
493 delete.assert();
494 }
495
496 #[test]
497 fn reads_current_pull_request_head_sha() {
498 let server = MockServer::start();
499 let get = server.mock(|when, then| {
500 when.method(GET)
501 .path("/repos/owner/repo/pulls/5")
502 .header("authorization", "Bearer tkn");
503 then.status(200).json_body(serde_json::json!({
504 "head": { "sha": "abcdef0123456789" }
505 }));
506 });
507
508 assert_eq!(
509 client(&server).pull_request_head_sha(5).unwrap(),
510 "abcdef0123456789"
511 );
512 get.assert();
513 }
514
515 #[test]
516 fn creates_check_and_appends_annotations_in_batches_of_fifty() {
517 let server = MockServer::start();
518 let annotations = (1..=51)
519 .map(|line| CheckAnnotation::warning(format!("src/file-{line}.rs"), line, line))
520 .collect::<Vec<_>>();
521 let first_batch = annotations.iter().take(50).collect::<Vec<_>>();
522 let create = server.mock(|when, then| {
523 when.method(POST)
524 .path("/repos/owner/repo/check-runs")
525 .json_body(serde_json::json!({
526 "name": "Badgers diff coverage",
527 "head_sha": "abc123",
528 "status": "completed",
529 "conclusion": "success",
530 "output": {
531 "title": "Coverage needs attention",
532 "summary": "51 ranges",
533 "annotations": first_batch,
534 },
535 }));
536 then.status(201).json_body(serde_json::json!({ "id": 99 }));
537 });
538 let update = server.mock(|when, then| {
539 when.method(httpmock::Method::PATCH)
540 .path("/repos/owner/repo/check-runs/99")
541 .json_body(serde_json::json!({
542 "output": {
543 "title": "Coverage needs attention",
544 "summary": "51 ranges",
545 "annotations": [&annotations[50]],
546 },
547 }));
548 then.status(200).json_body(serde_json::json!({ "id": 99 }));
549 });
550
551 let id = client(&server)
552 .create_check_run(
553 "Badgers diff coverage",
554 "abc123",
555 "Coverage needs attention",
556 "51 ranges",
557 &annotations,
558 )
559 .unwrap();
560 assert_eq!(id, 99);
561 create.assert();
562 update.assert();
563 }
564
565 #[test]
566 fn creates_successful_check_without_annotations() {
567 let server = MockServer::start();
568 let create = server.mock(|when, then| {
569 when.method(POST)
570 .path("/repos/owner/repo/check-runs")
571 .body_contains(r#""conclusion":"success""#)
572 .body_contains(r#""annotations":[]"#);
573 then.status(201).json_body(serde_json::json!({ "id": 7 }));
574 });
575
576 let id = client(&server)
577 .create_check_run("Badgers diff coverage", "abc123", "Covered", "No gaps", &[])
578 .unwrap();
579 assert_eq!(id, 7);
580 create.assert();
581 }
582
583 #[test]
584 fn annotation_messages_agree_with_line_ranges() {
585 assert_eq!(
586 CheckAnnotation::warning("src/lib.rs", 7, 7).message,
587 "Changed executable line 7 is not covered."
588 );
589 assert_eq!(
590 CheckAnnotation::warning("src/lib.rs", 7, 9).message,
591 "Changed executable lines 7-9 are not covered."
592 );
593 }
594}