cargo_unmaintained/github/real/
mod.rs1use super::GithubRepo;
2use crate::{RepoStatus, Url, curl};
3use anyhow::{Result, anyhow, bail};
4use elaborate::std::io::ReadContext;
5use std::{
6 cell::RefCell,
7 collections::{HashMap, HashSet},
8 rc::Rc,
9 time::{Duration, SystemTime},
10};
11
12mod map_ext;
13use map_ext::MapExt;
14
15pub mod util;
16
17const OK: u32 = 200;
18
19const GRAPHQL_URL: &str = "https://api.github.com/graphql";
23const BATCH_SIZE: usize = 100;
24
25thread_local! {
26 static REPOSITORY_CACHE: RefCell<HashMap<String, Option<Rc<serde_json::Value>>>> = RefCell::new(HashMap::new());
27}
28
29pub struct Impl;
30
31impl super::Github for Impl {
32 fn load_token(f: impl FnOnce(&str) -> Result<()>) -> Result<bool> {
33 util::load_token(f)
34 }
35
36 fn save_token() -> Result<()> {
37 util::save_token()
38 }
39
40 fn archival_status(url: Url) -> Result<RepoStatus<()>> {
41 let (url, owner, repo) = match_github_url(url)?;
42
43 let Some(repository) = repository(owner, repo)? else {
44 return Ok(RepoStatus::Nonexistent(url));
45 };
46
47 if repository
48 .as_object()
49 .and_then(|map| map.get_bool("archived"))
50 .unwrap_or_default()
51 {
52 Ok(RepoStatus::Archived(url))
53 } else {
54 Ok(RepoStatus::Success(url, ()))
55 }
56 }
57
58 fn prefetch<'a>(repos: &'a [GithubRepo<'a>]) -> Result<Vec<RepoStatus<'a, SystemTime>>> {
59 if repos.is_empty() {
60 return Ok(Vec::new());
61 }
62
63 let token = util::PERSONAL_TOKEN
64 .get()
65 .ok_or_else(|| anyhow!("no personal token available for GraphQL prefetch"))?;
66
67 let mut repo_statuses = Vec::with_capacity(repos.len());
68
69 for chunk in repos.chunks(BATCH_SIZE) {
70 let query = build_graphql_query(chunk);
71 let variables = build_graphql_variables(chunk);
72 let response = send_graphql_query(token, &query, &variables)?;
73 let chunk_statuses = parse_graphql_response(&response, chunk)?;
74 repo_statuses.extend(chunk_statuses);
75 }
76
77 Ok(repo_statuses)
78 }
79}
80
81fn repository(owner: &str, repo: &str) -> Result<Option<Rc<serde_json::Value>>> {
82 REPOSITORY_CACHE.with_borrow_mut(|repository_cache| {
83 let key = format!("{owner}/{repo}");
84
85 if let Some(repo) = repository_cache.get(&key) {
86 return Ok(repo.clone());
87 }
88
89 match repository_uncached(owner, repo) {
90 Ok(repository) => Ok(repository_cache
91 .entry(key)
92 .or_insert(Some(Rc::new(repository)))
93 .clone()),
94 Err(error) => {
95 repository_cache.insert(key, None);
96 Err(error)
97 }
98 }
99 })
100}
101
102fn repository_uncached(owner: &str, repo: &str) -> Result<serde_json::Value> {
103 call_api(owner, repo, None, &[])
104}
105
106fn match_github_url(url: Url<'_>) -> Result<(Url<'_>, &str, &str)> {
107 let Some(github_repo) = GithubRepo::from_url(url) else {
108 bail!("failed to match GitHub url: {url}");
109 };
110
111 Ok((github_repo.url, github_repo.owner, github_repo.repo))
112}
113
114fn call_api(
115 owner: &str,
116 repo: &str,
117 endpoint: Option<&str>,
118 mut data: &[u8],
119) -> Result<serde_json::Value> {
120 let url_string = format!(
121 "https://api.github.com/repos/{owner}/{repo}{}",
122 endpoint
123 .map(|endpoint| String::from("/") + endpoint)
124 .unwrap_or_default(),
125 );
126
127 let mut list = ::curl::easy::List::new();
128 list.append("User-Agent: cargo-unmaintained")?;
129 if let Some(token) = util::PERSONAL_TOKEN.get() {
130 list.append(&format!("Authorization: Bearer {token}"))?;
131 }
132
133 let mut handle = curl::handle(url_string.as_str().into())?;
134 handle.http_headers(list)?;
135 let mut response = Vec::new();
136 {
137 let mut transfer = handle.transfer();
138 transfer.read_function(|buf| {
139 #[allow(clippy::unwrap_used)]
140 let len = data.read_wc(buf).unwrap();
141 Ok(len)
142 })?;
143 transfer.write_function(|other| {
144 response.extend_from_slice(other);
145 Ok(other.len())
146 })?;
147 transfer.perform()?;
148 }
149
150 let response_code = handle.response_code()?;
151
152 if response_code != OK {
154 bail!("unexpected response code: {response_code}");
155 }
156
157 let value = serde_json::from_slice::<serde_json::Value>(&response)?;
158
159 Ok(value)
160}
161
162fn build_graphql_query(repos: &[GithubRepo<'_>]) -> String {
163 let mut query = String::from("query(");
170 for i in 0..repos.len() {
171 if i > 0 {
172 query.push_str(", ");
173 }
174 query.push_str(&format!("$owner{i}: String!, $name{i}: String!"));
175 }
176 query.push_str(") {");
177 for i in 0..repos.len() {
178 query.push_str(&format!(
179 r" repo{i}: repository(owner: $owner{i}, name: $name{i}) {{
180 isArchived
181 pushedAt
182 defaultBranchRef {{
183 target {{
184 ... on Commit {{
185 committedDate
186 }}
187 }}
188 }}
189 }}",
190 ));
191 }
192 query.push_str(" }");
193 query
194}
195
196fn build_graphql_variables(repos: &[GithubRepo<'_>]) -> serde_json::Map<String, serde_json::Value> {
197 repos
198 .iter()
199 .enumerate()
200 .flat_map(|(i, repo)| {
201 [
202 (format!("owner{i}"), serde_json::json!(repo.owner)),
203 (format!("name{i}"), serde_json::json!(repo.repo)),
204 ]
205 })
206 .collect()
207}
208
209fn send_graphql_query(
210 token: &str,
211 query: &str,
212 variables: &serde_json::Map<String, serde_json::Value>,
213) -> Result<serde_json::Value> {
214 let body = serde_json::json!({ "query": query, "variables": variables });
215 let body_bytes = serde_json::to_vec(&body)?;
216 let mut data: &[u8] = &body_bytes;
217
218 let mut list = ::curl::easy::List::new();
219 list.append("User-Agent: cargo-unmaintained")?;
220 list.append(&format!("Authorization: Bearer {token}"))?;
221 list.append("Content-Type: application/json")?;
222
223 let mut handle = curl::handle(GRAPHQL_URL.into())?;
224 handle.post(true)?;
225 let body_len = u64::try_from(body_bytes.len())?;
226 handle.post_field_size(body_len)?;
227 handle.http_headers(list)?;
228
229 let mut response = Vec::new();
230 {
231 let mut transfer = handle.transfer();
232 transfer.read_function(|buf| {
233 #[allow(clippy::unwrap_used)]
234 let len = data.read_wc(buf).unwrap();
235 Ok(len)
236 })?;
237 transfer.write_function(|other| {
238 response.extend_from_slice(other);
239 Ok(other.len())
240 })?;
241 transfer.perform()?;
242 }
243
244 let response_code = handle.response_code()?;
245 if response_code != OK {
246 bail!("GraphQL request failed with status {response_code}");
247 }
248
249 serde_json::from_slice(&response).map_err(Into::into)
250}
251
252fn parse_graphql_response<'a>(
253 response: &serde_json::Value,
254 repos: &'a [GithubRepo<'a>],
255) -> Result<Vec<RepoStatus<'a, SystemTime>>> {
256 let not_found_aliases = not_found_aliases(response)?;
257
258 let data = response
259 .as_object()
260 .and_then(|obj| obj.get("data"))
261 .and_then(serde_json::Value::as_object)
262 .ok_or_else(|| {
263 let errors = response.as_object().and_then(|obj| obj.get("errors"));
264 match errors {
265 Some(errs) => anyhow!("GraphQL query failed: {errs}"),
266 None => anyhow!("GraphQL response missing 'data' field"),
267 }
268 })?;
269
270 let mut repo_statuses = Vec::with_capacity(repos.len());
271
272 for (i, repo) in repos.iter().enumerate() {
273 let alias = format!("repo{i}");
274 let Some(repo_data) = data.get(&alias) else {
275 bail!("GraphQL response missing `{alias}` field");
276 };
277
278 if repo_data.is_null() {
279 if !not_found_aliases.contains(&alias) {
280 bail!("GraphQL response returned null for `{alias}` without a NOT_FOUND error");
281 }
282 repo_statuses.push(RepoStatus::Nonexistent(repo.url));
283 continue;
284 }
285
286 let Some(obj) = repo_data.as_object() else {
287 bail!("GraphQL response field `{alias}` is not an object");
288 };
289
290 let is_archived = obj.get_bool("isArchived").unwrap_or(false);
291
292 let timestamp_str = obj
295 .get("defaultBranchRef")
296 .and_then(serde_json::Value::as_object)
297 .and_then(|branch| branch.get("target"))
298 .and_then(serde_json::Value::as_object)
299 .and_then(|target| target.get_str("committedDate"))
300 .or_else(|| obj.get_str("pushedAt"));
301
302 if is_archived {
303 repo_statuses.push(RepoStatus::Archived(repo.url));
304 continue;
305 }
306
307 let Some(ts_str) = timestamp_str else {
308 repo_statuses.push(RepoStatus::Success(repo.url, SystemTime::UNIX_EPOCH));
310 continue;
311 };
312
313 let timestamp = parse_timestamp(ts_str)?;
314
315 repo_statuses.push(RepoStatus::Success(repo.url, timestamp));
316 }
317
318 Ok(repo_statuses)
319}
320
321fn not_found_aliases(response: &serde_json::Value) -> Result<HashSet<String>> {
322 let Some(errors) = response
323 .as_object()
324 .and_then(|obj| obj.get("errors"))
325 .and_then(serde_json::Value::as_array)
326 else {
327 return Ok(HashSet::new());
328 };
329
330 let mut aliases = HashSet::new();
331 for error in errors {
332 let error_type = error
333 .as_object()
334 .and_then(|obj| obj.get("type"))
335 .and_then(serde_json::Value::as_str);
336 let alias = error
337 .as_object()
338 .and_then(|obj| obj.get("path"))
339 .and_then(serde_json::Value::as_array)
340 .and_then(|path| path.first())
341 .and_then(serde_json::Value::as_str);
342
343 match (error_type, alias) {
344 (Some("NOT_FOUND"), Some(alias)) => {
345 aliases.insert(alias.to_owned());
346 }
347 _ => bail!("GraphQL query failed: {errors:?}"),
348 }
349 }
350
351 Ok(aliases)
352}
353
354fn parse_timestamp(iso: &str) -> Result<SystemTime> {
355 let date_time = chrono::DateTime::parse_from_rfc3339(iso)?;
356 let secs = u64::try_from(date_time.timestamp())?;
357 Ok(SystemTime::UNIX_EPOCH + Duration::from_secs(secs))
358}