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