• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dcdpr / jp / 26664039893

29 May 2026 09:50PM UTC coverage: 66.375% (+0.003%) from 66.372%
26664039893

push

github

web-flow
chore: reformat all markdown files using `comfort` (#699)

Signed-off-by: Jean Mertz <git@jeanmertz.com>

32028 of 48253 relevant lines covered (66.38%)

269.79 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

66.39
/crates/jp_github/src/client.rs
1
use std::sync::{Arc, Mutex, OnceLock};
2

3
use reqwest::{
4
    Client,
5
    header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderName, HeaderValue, USER_AGENT},
6
};
7
use serde::{Serialize, de::DeserializeOwned};
8
use serde_json::Value;
9

10
use crate::{
11
    Error, GitHubError, Page, Result, StatusCode,
12
    handlers::{CurrentHandler, IssuesHandler, PullsHandler, ReposHandler, SearchHandler},
13
};
14

15
#[derive(Clone)]
16
pub struct Octocrab {
17
    pub(crate) inner: Arc<Inner>,
18
}
19

20
pub(crate) struct Inner {
21
    pub(crate) client: Client,
22
    pub(crate) api_base: String,
23
    pub(crate) graphql_url: String,
24
}
25

26
pub struct OctocrabBuilder {
27
    token: Option<String>,
28
}
29

30
impl Octocrab {
31
    #[must_use]
32
    pub fn builder() -> OctocrabBuilder {
×
33
        OctocrabBuilder { token: None }
×
34
    }
×
35

36
    #[must_use]
37
    pub fn current(&self) -> CurrentHandler {
2✔
38
        CurrentHandler {
2✔
39
            client: self.clone(),
2✔
40
        }
2✔
41
    }
2✔
42

43
    #[must_use]
44
    pub fn issues(&self, owner: impl Into<String>, repo: impl Into<String>) -> IssuesHandler {
4✔
45
        IssuesHandler {
4✔
46
            client: self.clone(),
4✔
47
            owner: owner.into(),
4✔
48
            repo: repo.into(),
4✔
49
        }
4✔
50
    }
4✔
51

52
    #[must_use]
53
    pub fn pulls(&self, owner: impl Into<String>, repo: impl Into<String>) -> PullsHandler {
9✔
54
        PullsHandler {
9✔
55
            client: self.clone(),
9✔
56
            owner: owner.into(),
9✔
57
            repo: repo.into(),
9✔
58
        }
9✔
59
    }
9✔
60

61
    #[must_use]
62
    pub fn repos(&self, owner: impl Into<String>, repo: impl Into<String>) -> ReposHandler {
2✔
63
        ReposHandler {
2✔
64
            client: self.clone(),
2✔
65
            owner: owner.into(),
2✔
66
            repo: repo.into(),
2✔
67
        }
2✔
68
    }
2✔
69

70
    #[must_use]
71
    pub fn search(&self) -> SearchHandler {
1✔
72
        SearchHandler {
1✔
73
            client: self.clone(),
1✔
74
        }
1✔
75
    }
1✔
76

77
    pub async fn graphql<T: DeserializeOwned>(&self, body: &Value) -> Result<T> {
4✔
78
        let request = self.inner.client.post(&self.inner.graphql_url).json(body);
4✔
79
        self.send_json(request).await
4✔
80
    }
4✔
81

82
    #[allow(clippy::unused_async)] // Keep async for octocrab API compatibility at callsites.
83
    pub async fn all_pages<T>(&self, page: Page<T>) -> Result<Vec<T>> {
3✔
84
        Ok(page.items)
3✔
85
    }
3✔
86

87
    pub(crate) async fn get_json<T: DeserializeOwned>(
12✔
88
        &self,
12✔
89
        path: &str,
12✔
90
        query: &[(String, String)],
12✔
91
    ) -> Result<T> {
12✔
92
        let request = self
12✔
93
            .inner
12✔
94
            .client
12✔
95
            .get(format!("{}{}", self.inner.api_base, path))
12✔
96
            .query(query);
12✔
97

98
        self.send_json(request).await
12✔
99
    }
12✔
100

101
    pub(crate) async fn post_json<T: DeserializeOwned, B: Serialize>(
2✔
102
        &self,
2✔
103
        path: &str,
2✔
104
        body: &B,
2✔
105
    ) -> Result<T> {
2✔
106
        let request = self
2✔
107
            .inner
2✔
108
            .client
2✔
109
            .post(format!("{}{}", self.inner.api_base, path))
2✔
110
            .json(body);
2✔
111

112
        self.send_json(request).await
2✔
113
    }
2✔
114

115
    /// GET a path with a custom `Accept` header, returning the raw response
116
    /// body as a string.
117
    /// Used for endpoints that vary their content type by `Accept` (e.g.
118
    /// PR diffs via `application/vnd.github.diff`).
119
    pub(crate) async fn get_with_accept(&self, path: &str, accept: &str) -> Result<String> {
×
120
        let request = self
×
121
            .inner
×
122
            .client
×
123
            .get(format!("{}{}", self.inner.api_base, path))
×
124
            .header(reqwest::header::ACCEPT, accept);
×
125

126
        let response = request.send().await?;
×
127
        let status = response.status();
×
128
        let body = response.text().await?;
×
129

130
        if status.is_success() {
×
131
            return Ok(body);
×
132
        }
×
133

134
        let message = serde_json::from_str::<Value>(&body)
×
135
            .ok()
×
136
            .and_then(|value| {
×
137
                value
×
138
                    .get("message")
×
139
                    .and_then(Value::as_str)
×
140
                    .map(str::to_owned)
×
141
            })
×
142
            .unwrap_or_else(|| format!("request failed with status {}", status.as_u16()));
×
143

144
        Err(Error::GitHub {
×
145
            source: GitHubError {
×
146
                status_code: StatusCode::new(status.as_u16()),
×
147
                message,
×
148
            },
×
149
            body: Some(body),
×
150
        })
×
151
    }
×
152

153
    pub(crate) async fn delete_no_content(&self, path: &str) -> Result<()> {
1✔
154
        let request = self
1✔
155
            .inner
1✔
156
            .client
1✔
157
            .delete(format!("{}{}", self.inner.api_base, path));
1✔
158

159
        let response = request.send().await?;
1✔
160
        let status = response.status();
1✔
161

162
        if status.is_success() {
1✔
163
            return Ok(());
1✔
164
        }
×
165

166
        let body = response.text().await?;
×
167
        let message = serde_json::from_str::<Value>(&body)
×
168
            .ok()
×
169
            .and_then(|value| {
×
170
                value
×
171
                    .get("message")
×
172
                    .and_then(Value::as_str)
×
173
                    .map(str::to_owned)
×
174
            })
×
175
            .unwrap_or_else(|| format!("request failed with status {}", status.as_u16()));
×
176

177
        Err(Error::GitHub {
×
178
            source: GitHubError {
×
179
                status_code: StatusCode::new(status.as_u16()),
×
180
                message,
×
181
            },
×
182
            body: Some(body),
×
183
        })
×
184
    }
1✔
185

186
    pub(crate) async fn get_paginated<T: DeserializeOwned>(
2✔
187
        &self,
2✔
188
        path: &str,
2✔
189
        mut query: Vec<(String, String)>,
2✔
190
        per_page: u8,
2✔
191
    ) -> Result<Vec<T>> {
2✔
192
        let mut page = 1_u64;
2✔
193
        let mut out = vec![];
2✔
194

195
        loop {
196
            query.retain(|(key, _)| key != "page" && key != "per_page");
2✔
197
            query.push(("per_page".to_owned(), per_page.to_string()));
2✔
198
            query.push(("page".to_owned(), page.to_string()));
2✔
199

200
            let items: Vec<T> = self.get_json(path, &query).await?;
2✔
201
            let count = items.len();
2✔
202
            out.extend(items);
2✔
203

204
            if count == 0 || count < usize::from(per_page) {
2✔
205
                break;
2✔
206
            }
×
207

208
            page += 1;
×
209
        }
210

211
        Ok(out)
2✔
212
    }
2✔
213

214
    pub(crate) async fn get_search_paginated<T: DeserializeOwned>(
1✔
215
        &self,
1✔
216
        path: &str,
1✔
217
        mut query: Vec<(String, String)>,
1✔
218
        per_page: u8,
1✔
219
    ) -> Result<Vec<T>> {
1✔
220
        #[derive(serde::Deserialize)]
221
        struct SearchResponse<T> {
222
            items: Vec<T>,
223
        }
224

225
        let mut page = 1_u64;
1✔
226
        let mut out = vec![];
1✔
227

228
        loop {
229
            query.retain(|(key, _)| key != "page" && key != "per_page");
1✔
230
            query.push(("per_page".to_owned(), per_page.to_string()));
1✔
231
            query.push(("page".to_owned(), page.to_string()));
1✔
232

233
            let response: SearchResponse<T> = self.get_json(path, &query).await?;
1✔
234
            let count = response.items.len();
1✔
235
            out.extend(response.items);
1✔
236

237
            if count == 0 || count < usize::from(per_page) {
1✔
238
                break;
1✔
239
            }
×
240

241
            page += 1;
×
242
        }
243

244
        Ok(out)
1✔
245
    }
1✔
246

247
    async fn send_json<T: DeserializeOwned>(&self, request: reqwest::RequestBuilder) -> Result<T> {
18✔
248
        let response = request.send().await?;
18✔
249
        let status = response.status();
18✔
250
        let body = response.text().await?;
18✔
251

252
        if !status.is_success() {
18✔
253
            let message = serde_json::from_str::<Value>(&body)
1✔
254
                .ok()
1✔
255
                .and_then(|value| {
1✔
256
                    value
1✔
257
                        .get("message")
1✔
258
                        .and_then(Value::as_str)
1✔
259
                        .map(str::to_owned)
1✔
260
                })
1✔
261
                .unwrap_or_else(|| format!("request failed with status {}", status.as_u16()));
1✔
262

263
            return Err(Error::GitHub {
1✔
264
                source: GitHubError {
1✔
265
                    status_code: StatusCode::new(status.as_u16()),
1✔
266
                    message,
1✔
267
                },
1✔
268
                body: Some(body),
1✔
269
            });
1✔
270
        }
17✔
271

272
        serde_json::from_str(&body).map_err(Into::into)
17✔
273
    }
18✔
274

275
    #[cfg(test)]
276
    pub(crate) fn with_base_url(base_url: &str, token: Option<&str>) -> Self {
19✔
277
        let client = build_http_client(token).expect("test client to build");
19✔
278

279
        Self {
19✔
280
            inner: Arc::new(Inner {
19✔
281
                client,
19✔
282
                api_base: base_url.to_owned(),
19✔
283
                graphql_url: format!("{base_url}/graphql"),
19✔
284
            }),
19✔
285
        }
19✔
286
    }
19✔
287
}
288

289
impl OctocrabBuilder {
290
    #[must_use]
291
    pub fn personal_token(mut self, token: impl Into<String>) -> Self {
×
292
        self.token = Some(token.into());
×
293
        self
×
294
    }
×
295

296
    pub fn build(self) -> Result<Octocrab> {
×
297
        let client = build_http_client(self.token.as_deref())?;
×
298

299
        Ok(Octocrab {
×
300
            inner: Arc::new(Inner {
×
301
                client,
×
302
                api_base: "https://api.github.com".to_owned(),
×
303
                graphql_url: "https://api.github.com/graphql".to_owned(),
×
304
            }),
×
305
        })
×
306
    }
×
307
}
308

309
fn build_http_client(token: Option<&str>) -> Result<Client> {
19✔
310
    let mut headers = HeaderMap::new();
19✔
311
    headers.insert(USER_AGENT, HeaderValue::from_static("jp-github"));
19✔
312
    headers.insert(
19✔
313
        ACCEPT,
19✔
314
        HeaderValue::from_static("application/vnd.github+json"),
19✔
315
    );
316
    headers.insert(
19✔
317
        HeaderName::from_static("x-github-api-version"),
19✔
318
        HeaderValue::from_static("2022-11-28"),
19✔
319
    );
320

321
    if let Some(token) = token {
19✔
322
        let token = format!("Bearer {token}");
1✔
323
        headers.insert(AUTHORIZATION, HeaderValue::from_str(&token)?);
1✔
324
    }
18✔
325

326
    Client::builder()
19✔
327
        .default_headers(headers)
19✔
328
        .build()
19✔
329
        .map_err(|error| Error::Build(format!("{error:#}")))
19✔
330
}
19✔
331

332
static INSTANCE: OnceLock<Mutex<Option<Octocrab>>> = OnceLock::new();
333

334
pub fn initialise(client: Octocrab) {
×
335
    let mutex = INSTANCE.get_or_init(|| Mutex::new(None));
×
336
    if let Ok(mut lock) = mutex.lock() {
×
337
        *lock = Some(client);
×
338
    }
×
339
}
×
340

341
#[must_use]
342
/// Returns the globally initialized GitHub client.
343
///
344
/// # Panics
345
///
346
/// Panics if [`initialise`] has not been called successfully.
347
pub fn instance() -> Octocrab {
×
348
    let mutex = INSTANCE.get_or_init(|| Mutex::new(None));
×
349
    let lock = mutex.lock().ok();
×
350

351
    match lock.and_then(|guard| guard.as_ref().cloned()) {
×
352
        Some(client) => client,
×
353
        None => panic!("jp_github client not initialized"),
×
354
    }
355
}
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc