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
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    /// Update the PR comment containing `marker` if one exists, otherwise
123    /// create it (marker-based upsert per design doc ยง8.1).
124    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}