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

dcdpr / jp / 22154979281

18 Feb 2026 07:44PM UTC coverage: 55.288% (+1.3%) from 54.027%
22154979281

Pull #395

github

web-flow
Merge 0b5125385 into 76444fafa
Pull Request #395: Vet

780 of 1027 new or added lines in 36 files covered. (75.95%)

3 existing lines in 3 files now uncovered.

11606 of 20992 relevant lines covered (55.29%)

117.77 hits per line

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

82.61
/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]
NEW
32
    pub fn builder() -> OctocrabBuilder {
×
NEW
33
        OctocrabBuilder { token: None }
×
NEW
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 {
2✔
45
        IssuesHandler {
2✔
46
            client: self.clone(),
2✔
47
            owner: owner.into(),
2✔
48
            repo: repo.into(),
2✔
49
        }
2✔
50
    }
2✔
51

52
    #[must_use]
53
    pub fn pulls(&self, owner: impl Into<String>, repo: impl Into<String>) -> PullsHandler {
1✔
54
        PullsHandler {
1✔
55
            client: self.clone(),
1✔
56
            owner: owner.into(),
1✔
57
            repo: repo.into(),
1✔
58
        }
1✔
59
    }
1✔
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> {
1✔
78
        let request = self.inner.client.post(&self.inner.graphql_url).json(body);
1✔
79
        self.send_json(request).await
1✔
80
    }
1✔
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>> {
4✔
84
        Ok(page.items)
4✔
85
    }
4✔
86

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

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

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

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

115
    pub(crate) async fn get_paginated<T: DeserializeOwned>(
3✔
116
        &self,
3✔
117
        path: &str,
3✔
118
        mut query: Vec<(String, String)>,
3✔
119
        per_page: u8,
3✔
120
    ) -> Result<Vec<T>> {
3✔
121
        let mut page = 1_u64;
3✔
122
        let mut out = vec![];
3✔
123

124
        loop {
125
            query.retain(|(key, _)| key != "page" && key != "per_page");
4✔
126
            query.push(("per_page".to_owned(), per_page.to_string()));
4✔
127
            query.push(("page".to_owned(), page.to_string()));
4✔
128

129
            let items: Vec<T> = self.get_json(path, &query).await?;
4✔
130
            let count = items.len();
4✔
131
            out.extend(items);
4✔
132

133
            if count == 0 || count < usize::from(per_page) {
4✔
134
                break;
3✔
135
            }
1✔
136

137
            page += 1;
1✔
138
        }
139

140
        Ok(out)
3✔
141
    }
3✔
142

143
    pub(crate) async fn get_search_paginated<T: DeserializeOwned>(
1✔
144
        &self,
1✔
145
        path: &str,
1✔
146
        mut query: Vec<(String, String)>,
1✔
147
        per_page: u8,
1✔
148
    ) -> Result<Vec<T>> {
1✔
149
        #[derive(serde::Deserialize)]
150
        struct SearchResponse<T> {
151
            items: Vec<T>,
152
        }
153

154
        let mut page = 1_u64;
1✔
155
        let mut out = vec![];
1✔
156

157
        loop {
158
            query.retain(|(key, _)| key != "page" && key != "per_page");
1✔
159
            query.push(("per_page".to_owned(), per_page.to_string()));
1✔
160
            query.push(("page".to_owned(), page.to_string()));
1✔
161

162
            let response: SearchResponse<T> = self.get_json(path, &query).await?;
1✔
163
            let count = response.items.len();
1✔
164
            out.extend(response.items);
1✔
165

166
            if count == 0 || count < usize::from(per_page) {
1✔
167
                break;
1✔
NEW
168
            }
×
169

NEW
170
            page += 1;
×
171
        }
172

173
        Ok(out)
1✔
174
    }
1✔
175

176
    async fn send_json<T: DeserializeOwned>(&self, request: reqwest::RequestBuilder) -> Result<T> {
10✔
177
        let response = request.send().await?;
10✔
178
        let status = response.status();
10✔
179
        let body = response.text().await?;
10✔
180

181
        if !status.is_success() {
10✔
182
            let message = serde_json::from_str::<Value>(&body)
1✔
183
                .ok()
1✔
184
                .and_then(|value| {
1✔
185
                    value
1✔
186
                        .get("message")
1✔
187
                        .and_then(Value::as_str)
1✔
188
                        .map(str::to_owned)
1✔
189
                })
1✔
190
                .unwrap_or_else(|| format!("request failed with status {}", status.as_u16()));
1✔
191

192
            return Err(Error::GitHub {
1✔
193
                source: GitHubError {
1✔
194
                    status_code: StatusCode::new(status.as_u16()),
1✔
195
                    message,
1✔
196
                },
1✔
197
                body: Some(body),
1✔
198
            });
1✔
199
        }
9✔
200

201
        serde_json::from_str(&body).map_err(Into::into)
9✔
202
    }
10✔
203

204
    #[cfg(test)]
205
    pub(crate) fn with_base_url(base_url: &str, token: Option<&str>) -> Self {
9✔
206
        let client = build_http_client(token).expect("test client to build");
9✔
207

208
        Self {
9✔
209
            inner: Arc::new(Inner {
9✔
210
                client,
9✔
211
                api_base: base_url.to_owned(),
9✔
212
                graphql_url: format!("{base_url}/graphql"),
9✔
213
            }),
9✔
214
        }
9✔
215
    }
9✔
216
}
217

218
impl OctocrabBuilder {
219
    #[must_use]
NEW
220
    pub fn personal_token(mut self, token: impl Into<String>) -> Self {
×
NEW
221
        self.token = Some(token.into());
×
NEW
222
        self
×
NEW
223
    }
×
224

NEW
225
    pub fn build(self) -> Result<Octocrab> {
×
NEW
226
        let client = build_http_client(self.token.as_deref())?;
×
227

NEW
228
        Ok(Octocrab {
×
NEW
229
            inner: Arc::new(Inner {
×
NEW
230
                client,
×
NEW
231
                api_base: "https://api.github.com".to_owned(),
×
NEW
232
                graphql_url: "https://api.github.com/graphql".to_owned(),
×
NEW
233
            }),
×
NEW
234
        })
×
NEW
235
    }
×
236
}
237

238
fn build_http_client(token: Option<&str>) -> Result<Client> {
9✔
239
    let mut headers = HeaderMap::new();
9✔
240
    headers.insert(USER_AGENT, HeaderValue::from_static("jp-github"));
9✔
241
    headers.insert(
9✔
242
        ACCEPT,
9✔
243
        HeaderValue::from_static("application/vnd.github+json"),
9✔
244
    );
245
    headers.insert(
9✔
246
        HeaderName::from_static("x-github-api-version"),
9✔
247
        HeaderValue::from_static("2022-11-28"),
9✔
248
    );
249

250
    if let Some(token) = token {
9✔
251
        let token = format!("Bearer {token}");
1✔
252
        headers.insert(AUTHORIZATION, HeaderValue::from_str(&token)?);
1✔
253
    }
8✔
254

255
    Client::builder()
9✔
256
        .default_headers(headers)
9✔
257
        .build()
9✔
258
        .map_err(|error| Error::Build(format!("{error:#}")))
9✔
259
}
9✔
260

261
static INSTANCE: OnceLock<Mutex<Option<Octocrab>>> = OnceLock::new();
262

NEW
263
pub fn initialise(client: Octocrab) {
×
NEW
264
    let mutex = INSTANCE.get_or_init(|| Mutex::new(None));
×
NEW
265
    if let Ok(mut lock) = mutex.lock() {
×
NEW
266
        *lock = Some(client);
×
NEW
267
    }
×
NEW
268
}
×
269

270
#[must_use]
271
/// Returns the globally initialized GitHub client.
272
///
273
/// # Panics
274
///
275
/// Panics if [`initialise`] has not been called successfully.
NEW
276
pub fn instance() -> Octocrab {
×
NEW
277
    let mutex = INSTANCE.get_or_init(|| Mutex::new(None));
×
NEW
278
    let lock = mutex.lock().ok();
×
279

NEW
280
    match lock.and_then(|guard| guard.as_ref().cloned()) {
×
NEW
281
        Some(client) => client,
×
NEW
282
        None => panic!("jp_github client not initialized"),
×
283
    }
NEW
284
}
×
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