Skip to main content

cargo_unmaintained/github/real/
mod.rs

1use 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
19// GitHub GraphQL overview and call structure:
20// https://docs.github.com/graphql
21// https://docs.github.com/graphql/guides/forming-calls-with-graphql
22const 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    // smoelius: Should the next statement handle 404s, like `curl::existence` does?
153    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    // Query.repository(owner:, name:) reference:
164    // https://docs.github.com/graphql/reference/queries#repository
165    //
166    // Repository and Commit fields used below:
167    // https://docs.github.com/graphql/reference/objects#repository
168    // https://docs.github.com/graphql/reference/objects#commit
169    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        // Prefer committedDate (matches `git log -1 --pretty=format:%ct` on default branch)
293        // with pushedAt as fallback for empty repos.
294        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            // No timestamp data available (empty repo with no commits and no pushes).
309            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}