kowalski_core/tools/internal/
github.rs1use reqwest::blocking::{Client, Response};
9use std::time::Duration;
10
11const DEFAULT_TIMEOUT_SECS: u64 = 90;
12const MAX_BODY_CHARS: usize = 240_000;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum GithubFetchKind {
17 ReadmeApi,
18 RawUserContent,
19 PlainHttp,
20}
21
22#[derive(Debug, Clone)]
23pub struct FetchedUrlBody {
24 pub text: String,
25 pub kind: GithubFetchKind,
26 pub resolved_url: String,
27}
28
29fn http_client() -> Result<Client, String> {
30 Client::builder()
31 .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
32 .user_agent(concat!("Kowalski/", env!("CARGO_PKG_VERSION")))
33 .build()
34 .map_err(|e| e.to_string())
35}
36
37fn github_token() -> Option<String> {
38 std::env::var("GITHUB_TOKEN")
39 .ok()
40 .filter(|s| !s.trim().is_empty())
41}
42
43fn apply_github_auth(req: reqwest::blocking::RequestBuilder) -> reqwest::blocking::RequestBuilder {
44 if let Some(token) = github_token() {
45 req.header("Authorization", format!("Bearer {}", token.trim()))
46 } else {
47 req
48 }
49}
50
51fn github_tail(url: &str) -> Option<String> {
53 let u = url.trim().trim_end_matches('/');
54 for prefix in [
55 "https://github.com/",
56 "http://github.com/",
57 "https://www.github.com/",
58 "http://www.github.com/",
59 ] {
60 if let Some(t) = u.strip_prefix(prefix) {
61 return Some(t.to_string());
62 }
63 }
64 None
65}
66
67fn readme_api_url(owner: &str, repo: &str) -> String {
69 format!("https://api.github.com/repos/{owner}/{repo}/readme")
70}
71
72fn blob_url_to_raw(owner: &str, repo: &str, git_ref: &str, path: &str) -> String {
74 format!("https://raw.githubusercontent.com/{owner}/{repo}/{git_ref}/{path}")
75}
76
77pub fn resolve_github_fetch(url: &str) -> Option<ResolvedGithub> {
79 let tail = github_tail(url)?;
80 let segments: Vec<&str> = tail.split('/').filter(|s| !s.is_empty()).collect();
81 if segments.len() < 2 {
82 return None;
83 }
84 let owner = segments[0];
85 let repo = segments[1];
86
87 if segments.len() == 2 {
88 return Some(ResolvedGithub::Readme {
89 owner: owner.to_string(),
90 repo: repo.to_string(),
91 });
92 }
93
94 match segments.get(2).copied() {
95 Some("blob") if segments.len() >= 5 => {
96 let git_ref = segments[3];
97 let path = segments[4..].join("/");
98 Some(ResolvedGithub::RawFile {
99 owner: owner.to_string(),
100 repo: repo.to_string(),
101 git_ref: git_ref.to_string(),
102 path,
103 })
104 }
105 Some("raw") if segments.len() >= 5 => {
106 let git_ref = segments[3];
107 let path = segments[4..].join("/");
108 Some(ResolvedGithub::RawFile {
109 owner: owner.to_string(),
110 repo: repo.to_string(),
111 git_ref: git_ref.to_string(),
112 path,
113 })
114 }
115 _ => None,
116 }
117}
118
119#[derive(Debug, Clone)]
120pub enum ResolvedGithub {
121 Readme { owner: String, repo: String },
122 RawFile {
123 owner: String,
124 repo: String,
125 git_ref: String,
126 path: String,
127 },
128}
129
130impl ResolvedGithub {
131 fn fetch(&self, client: &Client) -> Result<Response, String> {
132 match self {
133 ResolvedGithub::Readme { owner, repo } => {
134 let u = readme_api_url(owner, repo);
135 let req = apply_github_auth(
136 client
137 .get(&u)
138 .header("Accept", "application/vnd.github.raw+json"),
139 );
140 req.send().map_err(|e| e.to_string())
141 }
142 ResolvedGithub::RawFile {
143 owner,
144 repo,
145 git_ref,
146 path,
147 } => {
148 let u = blob_url_to_raw(owner, repo, git_ref, path);
149 let req = apply_github_auth(client.get(&u));
150 req.send().map_err(|e| e.to_string())
151 }
152 }
153 }
154
155 fn resolved_url_display(&self) -> String {
156 match self {
157 ResolvedGithub::Readme { owner, repo } => readme_api_url(owner, repo),
158 ResolvedGithub::RawFile {
159 owner,
160 repo,
161 git_ref,
162 path,
163 } => blob_url_to_raw(owner, repo, git_ref, path),
164 }
165 }
166
167 fn fetch_kind(&self) -> GithubFetchKind {
168 match self {
169 ResolvedGithub::Readme { .. } => GithubFetchKind::ReadmeApi,
170 ResolvedGithub::RawFile { .. } => GithubFetchKind::RawUserContent,
171 }
172 }
173}
174
175fn read_response_text(resp: Response) -> Result<String, String> {
176 if !resp.status().is_success() {
177 return Err(format!(
178 "HTTP {} {}",
179 resp.status().as_u16(),
180 resp.status().canonical_reason().unwrap_or("")
181 ));
182 }
183 let text = resp
184 .text()
185 .map_err(|e| e.to_string())?
186 .chars()
187 .take(MAX_BODY_CHARS)
188 .collect::<String>();
189 Ok(text)
190}
191
192pub fn fetch_url_for_ingest(original_url: &str) -> Result<FetchedUrlBody, String> {
195 let client = http_client()?;
196
197 if let Some(resolved) = resolve_github_fetch(original_url) {
198 let kind = resolved.fetch_kind();
199 let resolved_url = resolved.resolved_url_display();
200 if let Ok(resp) = resolved.fetch(&client)
201 && let Ok(text) = read_response_text(resp)
202 {
203 return Ok(FetchedUrlBody {
204 text,
205 kind,
206 resolved_url,
207 });
208 }
209 }
211
212 let resp = client.get(original_url).send().map_err(|e| e.to_string())?;
213 let text = read_response_text(resp)?;
214 Ok(FetchedUrlBody {
215 text,
216 kind: GithubFetchKind::PlainHttp,
217 resolved_url: original_url.to_string(),
218 })
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn readme_repo_only() {
227 let r = resolve_github_fetch("https://github.com/octocat/Hello-World").unwrap();
228 match r {
229 ResolvedGithub::Readme { owner, repo } => {
230 assert_eq!(owner, "octocat");
231 assert_eq!(repo, "Hello-World");
232 }
233 _ => panic!("expected Readme"),
234 }
235 }
236
237 #[test]
238 fn readme_trailing_slash() {
239 assert!(resolve_github_fetch("https://github.com/foo/bar/").is_some());
240 }
241
242 #[test]
243 fn blob_to_raw_resolution() {
244 let r = resolve_github_fetch(
245 "https://github.com/rust-lang/rust/blob/master/README.md",
246 )
247 .unwrap();
248 match r {
249 ResolvedGithub::RawFile {
250 owner,
251 repo,
252 git_ref,
253 path,
254 } => {
255 assert_eq!(owner, "rust-lang");
256 assert_eq!(repo, "rust");
257 assert_eq!(git_ref, "master");
258 assert_eq!(path, "README.md");
259 }
260 _ => panic!("expected RawFile"),
261 }
262 }
263
264 #[test]
265 fn nested_blob_path() {
266 let r = resolve_github_fetch(
267 "https://github.com/o/r/blob/main/docs/guide.md",
268 )
269 .unwrap();
270 match r {
271 ResolvedGithub::RawFile { path, .. } => assert_eq!(path, "docs/guide.md"),
272 _ => panic!("expected RawFile"),
273 }
274 }
275
276 #[test]
277 fn non_github_returns_none() {
278 assert!(resolve_github_fetch("https://example.com/page").is_none());
279 }
280
281 #[test]
282 fn raw_display_url() {
283 let r = resolve_github_fetch(
284 "https://github.com/a/b/blob/v1.0/Cargo.toml",
285 )
286 .unwrap();
287 assert_eq!(
288 r.resolved_url_display(),
289 "https://raw.githubusercontent.com/a/b/v1.0/Cargo.toml"
290 );
291 }
292}