redskull_lib/
github_graphql.rs1use 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
17pub struct RepoDiscovery {
19 pub release_tags: Vec<String>,
21 pub ref_tags: Vec<String>,
23 pub tree: Vec<String>,
25 pub root_cargo_toml: Option<String>,
27}
28
29pub fn discover_repo(client: &Client, repo: &GitHubRepo, tag: &str) -> Result<RepoDiscovery> {
32 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 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 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 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 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 let tree = parse_tree_entries(data.get("tree"), "");
130
131 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
141pub 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 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
207pub 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
236fn 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
245fn 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 paths.push(full_path.clone());
265 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
275fn 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}