Skip to main content

redskull_lib/
github_graphql.rs

1//! GitHub GraphQL API client.
2//!
3//! Consolidates multiple REST API calls into 1-2 GraphQL queries per crate:
4//! - Releases + tags (for version detection)
5//! - Repository tree (for license/runtime dep detection)
6//! - File contents (Cargo.toml, workspace member Cargo.tomls)
7//!
8//! The archive download for SHA256 computation still uses REST (no GraphQL equivalent).
9
10use anyhow::{Result, anyhow};
11use reqwest::blocking::Client;
12
13use crate::source::{GitHubRepo, is_prerelease_tag, looks_like_version_tag, tag_to_version};
14
15const GRAPHQL_URL: &str = "https://api.github.com/graphql";
16
17/// Result of the initial discovery query: releases, tags, tree, and root Cargo.toml.
18pub struct RepoDiscovery {
19    /// Version-like release tags, most recent first.
20    pub release_tags: Vec<String>,
21    /// Version-like ref tags, most recent first.
22    pub ref_tags: Vec<String>,
23    /// All file paths in the repository (recursive tree).
24    pub tree: Vec<String>,
25    /// Root Cargo.toml contents, if present.
26    pub root_cargo_toml: Option<String>,
27}
28
29/// Execute the discovery query: fetch releases, tags, recursive tree, and root Cargo.toml
30/// in a single GraphQL call.
31pub fn discover_repo(client: &Client, repo: &GitHubRepo, tag: &str) -> Result<RepoDiscovery> {
32    // Build the tree fragment 5 levels deep (covers most workspace layouts)
33    let tree_fragment = tree_entries_fragment(5);
34
35    let query = format!(
36        r#"query($owner: String!, $repo: String!) {{
37  repository(owner: $owner, name: $repo) {{
38    releases(first: 10, orderBy: {{field: CREATED_AT, direction: DESC}}) {{
39      nodes {{ tagName isPrerelease isDraft }}
40    }}
41    refs(refPrefix: "refs/tags/", first: 10, orderBy: {{field: TAG_COMMIT_DATE, direction: DESC}}) {{
42      nodes {{ name }}
43    }}
44    rootCargo: object(expression: "{tag}:Cargo.toml") {{
45      ... on Blob {{ text }}
46    }}
47    tree: object(expression: "{tag}:") {{
48      ... on Tree {{
49        {tree_fragment}
50      }}
51    }}
52  }}
53}}"#
54    );
55
56    let body = serde_json::json!({
57        "query": query,
58        "variables": {
59            "owner": repo.owner,
60            "repo": repo.name,
61        }
62    });
63
64    let resp = client.post(GRAPHQL_URL).header("Accept", "application/json").json(&body).send()?;
65
66    if !resp.status().is_success() {
67        return Err(anyhow!(
68            "GraphQL query failed for {}/{}: HTTP {}",
69            repo.owner,
70            repo.name,
71            resp.status()
72        ));
73    }
74
75    let json: serde_json::Value = resp.json()?;
76
77    // Check for GraphQL errors
78    if let Some(errors) = json.get("errors").and_then(|e| e.as_array()) {
79        if !errors.is_empty() {
80            let msg = errors
81                .iter()
82                .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
83                .collect::<Vec<_>>()
84                .join("; ");
85            return Err(anyhow!("GraphQL errors for {}/{}: {msg}", repo.owner, repo.name));
86        }
87    }
88
89    let data = json
90        .get("data")
91        .and_then(|d| d.get("repository"))
92        .ok_or_else(|| anyhow!("No repository data in GraphQL response"))?;
93
94    // Parse releases
95    let release_tags: Vec<String> = data
96        .get("releases")
97        .and_then(|r| r.get("nodes"))
98        .and_then(|n| n.as_array())
99        .map(|nodes| {
100            nodes
101                .iter()
102                .filter(|n| {
103                    // Skip prereleases and drafts
104                    let pre = n.get("isPrerelease").and_then(|v| v.as_bool()).unwrap_or(false);
105                    let draft = n.get("isDraft").and_then(|v| v.as_bool()).unwrap_or(false);
106                    !pre && !draft
107                })
108                .filter_map(|n| n.get("tagName").and_then(|t| t.as_str()).map(String::from))
109                .filter(|t| looks_like_version_tag(t))
110                .collect()
111        })
112        .unwrap_or_default();
113
114    // Parse tags
115    let ref_tags: Vec<String> = data
116        .get("refs")
117        .and_then(|r| r.get("nodes"))
118        .and_then(|n| n.as_array())
119        .map(|nodes| {
120            nodes
121                .iter()
122                .filter_map(|n| n.get("name").and_then(|t| t.as_str()).map(String::from))
123                .filter(|t| looks_like_version_tag(t) && !is_prerelease_tag(t))
124                .collect()
125        })
126        .unwrap_or_default();
127
128    // Parse tree recursively
129    let tree = parse_tree_entries(data.get("tree"), "");
130
131    // Parse root Cargo.toml
132    let root_cargo_toml = data
133        .get("rootCargo")
134        .and_then(|o| o.get("text"))
135        .and_then(|t| t.as_str())
136        .map(String::from);
137
138    Ok(RepoDiscovery { release_tags, ref_tags, tree, root_cargo_toml })
139}
140
141/// Fetch multiple file contents in a single GraphQL query using aliases.
142/// Returns a Vec of (path, contents) pairs for files that exist.
143pub fn fetch_files(
144    client: &Client,
145    repo: &GitHubRepo,
146    tag: &str,
147    paths: &[String],
148) -> Result<Vec<(String, String)>> {
149    if paths.is_empty() {
150        return Ok(vec![]);
151    }
152
153    // Build aliased object queries for each path
154    let file_queries: Vec<String> = paths
155        .iter()
156        .enumerate()
157        .map(|(i, path)| {
158            format!(
159                r#"  file_{i}: object(expression: "{tag}:{path}") {{
160    ... on Blob {{ text }}
161  }}"#
162            )
163        })
164        .collect();
165
166    let query = format!(
167        "query($owner: String!, $repo: String!) {{\n  repository(owner: $owner, name: $repo) {{\n{}\n  }}\n}}",
168        file_queries.join("\n")
169    );
170
171    let body = serde_json::json!({
172        "query": query,
173        "variables": {
174            "owner": repo.owner,
175            "repo": repo.name,
176        }
177    });
178
179    let resp = client.post(GRAPHQL_URL).header("Accept", "application/json").json(&body).send()?;
180
181    if !resp.status().is_success() {
182        return Err(anyhow!(
183            "GraphQL file fetch failed for {}/{}: HTTP {}",
184            repo.owner,
185            repo.name,
186            resp.status()
187        ));
188    }
189
190    let json: serde_json::Value = resp.json()?;
191    let data = json
192        .get("data")
193        .and_then(|d| d.get("repository"))
194        .ok_or_else(|| anyhow!("No repository data in GraphQL response"))?;
195
196    let mut results = Vec::new();
197    for (i, path) in paths.iter().enumerate() {
198        let alias = format!("file_{i}");
199        if let Some(text) = data.get(&alias).and_then(|o| o.get("text")).and_then(|t| t.as_str()) {
200            results.push((path.clone(), text.to_string()));
201        }
202    }
203
204    Ok(results)
205}
206
207/// Pick the best version tag from discovery results.
208/// Compares release tags and ref tags, preferring the newer version.
209pub fn best_version_tag(discovery: &RepoDiscovery) -> Option<String> {
210    let release = discovery.release_tags.first();
211    let tag = discovery.ref_tags.first();
212
213    match (release, tag) {
214        (Some(r), Some(t)) => {
215            if r == t {
216                Some(r.clone())
217            } else {
218                let rv = tag_to_version(r);
219                let tv = tag_to_version(t);
220                if compare_version_strings(&tv, &rv) {
221                    log::info!(
222                        "Tags API has newer version '{t}' than latest release '{r}'; using tags"
223                    );
224                    Some(t.clone())
225                } else {
226                    Some(r.clone())
227                }
228            }
229        }
230        (Some(r), None) => Some(r.clone()),
231        (None, Some(t)) => Some(t.clone()),
232        (None, None) => None,
233    }
234}
235
236/// Build a GraphQL tree entries fragment to a given depth.
237fn tree_entries_fragment(depth: usize) -> String {
238    if depth == 0 {
239        return "entries { name type }".to_string();
240    }
241    let inner = tree_entries_fragment(depth - 1);
242    format!("entries {{ name type object {{ ... on Tree {{ {inner} }} }} }}")
243}
244
245/// Recursively parse tree entries from GraphQL response into flat file paths.
246fn parse_tree_entries(node: Option<&serde_json::Value>, prefix: &str) -> Vec<String> {
247    let mut paths = Vec::new();
248    let Some(entries) = node.and_then(|n| n.get("entries")).and_then(|e| e.as_array()) else {
249        return paths;
250    };
251
252    for entry in entries {
253        let Some(name) = entry.get("name").and_then(|n| n.as_str()) else {
254            continue;
255        };
256        let entry_type = entry.get("type").and_then(|t| t.as_str()).unwrap_or("");
257        let full_path =
258            if prefix.is_empty() { name.to_string() } else { format!("{prefix}/{name}") };
259
260        match entry_type {
261            "blob" => paths.push(full_path),
262            "tree" => {
263                // Add the directory path itself
264                paths.push(full_path.clone());
265                // Recurse into subdirectory
266                let subtree = entry.get("object");
267                paths.extend(parse_tree_entries(subtree, &full_path));
268            }
269            _ => paths.push(full_path),
270        }
271    }
272    paths
273}
274
275/// Compare two version strings, returning true if `a` is greater than `b`.
276fn compare_version_strings(a: &str, b: &str) -> bool {
277    let parse_segments = |s: &str| -> Vec<u64> {
278        s.split(['.', '-']).filter_map(|seg| seg.parse::<u64>().ok()).collect()
279    };
280    parse_segments(a) > parse_segments(b)
281}