Skip to main content

badge_rs_github/
lib.rs

1//! Minimal GitHub REST client for badgers PR reporting.
2
3use serde::Deserialize;
4
5#[derive(Debug, thiserror::Error)]
6pub enum GithubError {
7    #[error("github http error: {0}")]
8    Http(#[from] reqwest::Error),
9    #[error("github api returned {status}: {body}")]
10    UnexpectedStatus { status: u16, body: String },
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum CommentAction {
15    Created,
16    Updated,
17}
18
19pub struct GithubClient {
20    client: reqwest::blocking::Client,
21    base_url: String,
22    repo: String,
23    token: String,
24}
25
26#[derive(Deserialize)]
27struct IssueComment {
28    id: u64,
29    body: Option<String>,
30}
31
32const PER_PAGE: u32 = 100;
33const MAX_PAGES: u32 = 10;
34
35impl GithubClient {
36    pub fn new(repo: impl Into<String>, token: impl Into<String>) -> Self {
37        Self::with_base_url(repo, token, "https://api.github.com")
38    }
39
40    pub fn with_base_url(
41        repo: impl Into<String>,
42        token: impl Into<String>,
43        base_url: impl Into<String>,
44    ) -> Self {
45        Self {
46            client: reqwest::blocking::Client::new(),
47            base_url: base_url.into().trim_end_matches('/').to_string(),
48            repo: repo.into(),
49            token: token.into(),
50        }
51    }
52
53    /// Update the PR comment containing `marker` if one exists, otherwise
54    /// create it (marker-based upsert per design doc ยง8.1).
55    pub fn upsert_pr_comment(
56        &self,
57        pr_number: u64,
58        marker: &str,
59        body: &str,
60    ) -> Result<CommentAction, GithubError> {
61        if let Some(comment_id) = self.find_comment(pr_number, marker)? {
62            let url = format!(
63                "{}/repos/{}/issues/comments/{comment_id}",
64                self.base_url, self.repo
65            );
66            let resp = self
67                .request(self.client.patch(&url))
68                .json(&serde_json::json!({ "body": body }))
69                .send()?;
70            expect_success(resp)?;
71            Ok(CommentAction::Updated)
72        } else {
73            let url = format!(
74                "{}/repos/{}/issues/{pr_number}/comments",
75                self.base_url, self.repo
76            );
77            let resp = self
78                .request(self.client.post(&url))
79                .json(&serde_json::json!({ "body": body }))
80                .send()?;
81            expect_success(resp)?;
82            Ok(CommentAction::Created)
83        }
84    }
85
86    fn find_comment(&self, pr_number: u64, marker: &str) -> Result<Option<u64>, GithubError> {
87        for page in 1..=MAX_PAGES {
88            let url = format!(
89                "{}/repos/{}/issues/{pr_number}/comments?per_page={PER_PAGE}&page={page}",
90                self.base_url, self.repo
91            );
92            let resp = self.request(self.client.get(&url)).send()?;
93            let resp = expect_success(resp)?;
94            let comments: Vec<IssueComment> = resp.json()?;
95            let count = comments.len();
96            for comment in comments {
97                if comment.body.is_some_and(|b| b.contains(marker)) {
98                    return Ok(Some(comment.id));
99                }
100            }
101            if count < PER_PAGE as usize {
102                break;
103            }
104        }
105        Ok(None)
106    }
107
108    fn request(
109        &self,
110        builder: reqwest::blocking::RequestBuilder,
111    ) -> reqwest::blocking::RequestBuilder {
112        builder
113            .bearer_auth(&self.token)
114            .header("accept", "application/vnd.github+json")
115            .header("x-github-api-version", "2022-11-28")
116            .header("user-agent", "badgers")
117    }
118}
119
120fn expect_success(
121    resp: reqwest::blocking::Response,
122) -> Result<reqwest::blocking::Response, GithubError> {
123    let status = resp.status();
124    if status.is_success() {
125        Ok(resp)
126    } else {
127        Err(GithubError::UnexpectedStatus {
128            status: status.as_u16(),
129            body: resp.text().unwrap_or_default(),
130        })
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use httpmock::prelude::*;
137
138    use super::*;
139
140    fn client(server: &MockServer) -> GithubClient {
141        GithubClient::with_base_url("owner/repo", "tkn", server.base_url())
142    }
143
144    #[test]
145    fn creates_comment_when_marker_absent() {
146        let server = MockServer::start();
147        server.mock(|when, then| {
148            when.method(GET)
149                .path("/repos/owner/repo/issues/5/comments")
150                .header("authorization", "Bearer tkn");
151            then.status(200).json_body(serde_json::json!([
152                { "id": 1, "body": "unrelated" }
153            ]));
154        });
155        let post = server.mock(|when, then| {
156            when.method(POST)
157                .path("/repos/owner/repo/issues/5/comments")
158                .body_contains("badgers-report");
159            then.status(201).json_body(serde_json::json!({ "id": 2 }));
160        });
161
162        let action = client(&server)
163            .upsert_pr_comment(
164                5,
165                "<!-- badgers-report:owner/repo:5 -->",
166                "<!-- badgers-report:owner/repo:5 -->\nhello",
167            )
168            .unwrap();
169        assert_eq!(action, CommentAction::Created);
170        post.assert();
171    }
172
173    #[test]
174    fn updates_existing_marked_comment() {
175        let server = MockServer::start();
176        server.mock(|when, then| {
177            when.method(GET).path("/repos/owner/repo/issues/5/comments");
178            then.status(200).json_body(serde_json::json!([
179                { "id": 7, "body": "<!-- badgers-report:owner/repo:5 -->\nold" }
180            ]));
181        });
182        let patch = server.mock(|when, then| {
183            when.method(httpmock::Method::PATCH)
184                .path("/repos/owner/repo/issues/comments/7")
185                .body_contains("new body");
186            then.status(200).json_body(serde_json::json!({ "id": 7 }));
187        });
188
189        let action = client(&server)
190            .upsert_pr_comment(5, "<!-- badgers-report:owner/repo:5 -->", "new body")
191            .unwrap();
192        assert_eq!(action, CommentAction::Updated);
193        patch.assert();
194    }
195
196    #[test]
197    fn surfaces_permission_errors_with_status() {
198        let server = MockServer::start();
199        server.mock(|when, then| {
200            when.method(GET).path("/repos/owner/repo/issues/5/comments");
201            then.status(403)
202                .body("Resource not accessible by integration");
203        });
204
205        let err = client(&server).upsert_pr_comment(5, "m", "b").unwrap_err();
206        assert!(matches!(
207            err,
208            GithubError::UnexpectedStatus { status: 403, .. }
209        ));
210    }
211}