speccoder_runtime/github/
http.rs1use serde_json::Value;
6
7use super::auth::{SCOPES, Secret};
8
9const LIMIT: std::time::Duration = std::time::Duration::from_secs(30);
11
12fn client() -> Result<reqwest::Client, String> {
13 reqwest::Client::builder()
14 .user_agent(concat!("speccoder/", env!("CARGO_PKG_VERSION")))
15 .connect_timeout(std::time::Duration::from_secs(10))
16 .timeout(LIMIT)
17 .build()
18 .map_err(|e| e.to_string())
19}
20
21fn block<T>(f: impl std::future::Future<Output = Result<T, String>>) -> Result<T, String> {
22 crate::pipeline::tokio_runtime()?.block_on(f)
23}
24
25#[must_use]
28pub fn endpoints(host: &str, base: Option<&str>) -> (String, String) {
29 if let Some(base) = base.filter(|b| !b.trim().is_empty()) {
30 let base = base.trim_end_matches('/').to_owned();
31 return (base.clone(), format!("{base}/graphql"));
32 }
33 if host == "github.com" {
34 (
35 "https://api.github.com".to_owned(),
36 "https://api.github.com/graphql".to_owned(),
37 )
38 } else {
39 (
40 format!("https://{host}/api/v3"),
41 format!("https://{host}/api/graphql"),
42 )
43 }
44}
45
46#[must_use]
48pub fn oauth(host: &str) -> String {
49 std::env::var("SPECCODER_GITHUB_OAUTH")
50 .ok()
51 .filter(|b| !b.trim().is_empty())
52 .unwrap_or_else(|| format!("https://{host}"))
53 .trim_end_matches('/')
54 .to_owned()
55}
56
57#[derive(Debug, Clone)]
59pub struct Api {
60 host: String,
61 rest: String,
62 graphql: String,
63 token: Secret,
64}
65
66impl Api {
67 #[must_use]
69 pub fn new(host: &str, token: Secret, base: Option<&str>) -> Self {
70 let (rest, graphql) = endpoints(host, base);
71 Self {
72 host: host.to_owned(),
73 rest,
74 graphql,
75 token,
76 }
77 }
78
79 fn request(
80 &self,
81 method: reqwest::Method,
82 url: &str,
83 ) -> Result<reqwest::RequestBuilder, String> {
84 Ok(client()?
85 .request(method, url)
86 .bearer_auth(self.token.expose())
87 .header(reqwest::header::ACCEPT, "application/vnd.github+json")
88 .header("X-GitHub-Api-Version", "2022-11-28"))
89 }
90
91 pub fn rest(
97 &self,
98 method: reqwest::Method,
99 path: &str,
100 body: Option<&Value>,
101 ) -> Result<Value, String> {
102 let url = format!("{}{path}", self.rest);
103 let mut req = self.request(method, &url)?;
104 if let Some(b) = body {
105 req = req.json(b);
106 }
107 block(async move {
108 let resp = req.send().await.map_err(|e| unreachable(&self.host, &e))?;
109 answer(&self.host, path, resp).await
110 })
111 }
112
113 pub fn get(&self, path: &str) -> Result<Value, String> {
119 self.rest(reqwest::Method::GET, path, None)
120 }
121
122 pub fn get_diff(&self, path: &str) -> Result<String, String> {
129 let url = format!("{}{path}", self.rest);
130 let req = self
131 .request(reqwest::Method::GET, &url)?
132 .header(reqwest::header::ACCEPT, "application/vnd.github.diff");
133 let body = block(async move {
134 let resp = req.send().await.map_err(|e| unreachable(&self.host, &e))?;
135 answer(&self.host, path, resp).await
136 })?;
137 Ok(match body {
138 Value::String(s) => s,
139 Value::Null => String::new(),
140 other => other.to_string(),
141 })
142 }
143
144 pub fn graphql(&self, query: &str, variables: Value) -> Result<Value, String> {
150 let req = self
151 .request(reqwest::Method::POST, &self.graphql)?
152 .json(&serde_json::json!({ "query": query, "variables": variables }));
153 let body = block(async move {
154 let resp = req.send().await.map_err(|e| unreachable(&self.host, &e))?;
155 answer(&self.host, "graphql", resp).await
156 })?;
157 if let Some(e) = body["errors"].as_array().and_then(|e| e.first()) {
158 return Err(format!(
159 "GitHub: {}",
160 e["message"].as_str().unwrap_or("the query failed")
161 ));
162 }
163 Ok(body["data"].clone())
164 }
165}
166
167pub(crate) fn form(url: &str, fields: &[(&str, &str)]) -> Result<Value, String> {
169 let req = client()?
170 .post(url)
171 .header(reqwest::header::ACCEPT, "application/json")
172 .form(fields);
173 block(async move {
174 let resp = req.send().await.map_err(|e| format!("{url}: {e}"))?;
175 resp.json::<Value>()
176 .await
177 .map_err(|e| format!("{url}: {e}"))
178 })
179}
180
181fn unreachable(host: &str, e: &reqwest::Error) -> String {
182 if e.is_timeout() {
183 format!("{host} did not answer within {}s", LIMIT.as_secs())
184 } else {
185 format!("{host} could not be reached: {e}")
186 }
187}
188
189async fn answer(host: &str, what: &str, resp: reqwest::Response) -> Result<Value, String> {
191 let status = resp.status();
192 let remaining = resp
193 .headers()
194 .get("x-ratelimit-remaining")
195 .and_then(|v| v.to_str().ok())
196 .map(str::to_owned);
197 let reset = resp
198 .headers()
199 .get("x-ratelimit-reset")
200 .and_then(|v| v.to_str().ok())
201 .and_then(|v| v.parse::<u64>().ok());
202 let text = resp.text().await.unwrap_or_default();
203 let body: Value = if text.trim().is_empty() {
204 Value::Null
205 } else {
206 serde_json::from_str(&text).unwrap_or(Value::String(text))
207 };
208 if status.is_success() {
209 return Ok(body);
210 }
211 let says = body["message"].as_str().unwrap_or_default().to_owned();
212 Err(match status.as_u16() {
213 401 => format!("{host} refused the token (expired or revoked): `speccoder auth login`"),
214 403 | 429 if remaining.as_deref() == Some("0") => {
215 let now = std::time::SystemTime::now()
216 .duration_since(std::time::UNIX_EPOCH)
217 .map_or(0, |d| d.as_secs());
218 let wait = reset.map_or(60, |r| r.saturating_sub(now));
219 format!(
220 "{host}'s rate limit is used up; it resets in {} min",
221 wait.div_ceil(60).max(1)
222 )
223 }
224 403 => format!(
225 "the token may not do this ({says}); it needs `{SCOPES}`, or for a fine-grained token: Issues read, Pull requests read and write, Contents and Metadata read"
226 ),
227 404 if host == "github.com" => format!(
228 "{what} was not found on {host}: the repository, or the token cannot see it — signed in with `speccoder auth login`? Install the {} app on the repository: {}",
229 super::auth::APP_SLUG,
230 super::auth::install_url()
231 ),
232 404 => {
233 format!("{what} was not found on {host}: the repository, or the token cannot see it")
234 }
235 422 => {
236 let detail: Vec<String> = body["errors"]
237 .as_array()
238 .into_iter()
239 .flatten()
240 .filter_map(|e| e["message"].as_str().map(str::to_owned))
241 .collect();
242 if detail.is_empty() {
243 format!("GitHub refused it: {says}")
244 } else {
245 format!("GitHub refused it: {says} ({})", detail.join("; "))
246 }
247 }
248 code => format!("GitHub answered {code}: {says}"),
249 })
250}