Skip to main content

badge_rs_github/
lib.rs

1//! Minimal GitHub REST client for badgers PR comments and check annotations.
2
3use 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    /// Update the PR comment containing `marker` if one exists, otherwise
139    /// create it (marker-based upsert per design doc ยง8.1).
140    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}