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 graphql(&self, query: &str, variables: Value) -> Result<Value, String> {
128 let req = self
129 .request(reqwest::Method::POST, &self.graphql)?
130 .json(&serde_json::json!({ "query": query, "variables": variables }));
131 let body = block(async move {
132 let resp = req.send().await.map_err(|e| unreachable(&self.host, &e))?;
133 answer(&self.host, "graphql", resp).await
134 })?;
135 if let Some(e) = body["errors"].as_array().and_then(|e| e.first()) {
136 return Err(format!(
137 "GitHub: {}",
138 e["message"].as_str().unwrap_or("the query failed")
139 ));
140 }
141 Ok(body["data"].clone())
142 }
143}
144
145pub(crate) fn form(url: &str, fields: &[(&str, &str)]) -> Result<Value, String> {
147 let req = client()?
148 .post(url)
149 .header(reqwest::header::ACCEPT, "application/json")
150 .form(fields);
151 block(async move {
152 let resp = req.send().await.map_err(|e| format!("{url}: {e}"))?;
153 resp.json::<Value>()
154 .await
155 .map_err(|e| format!("{url}: {e}"))
156 })
157}
158
159fn unreachable(host: &str, e: &reqwest::Error) -> String {
160 if e.is_timeout() {
161 format!("{host} did not answer within {}s", LIMIT.as_secs())
162 } else {
163 format!("{host} could not be reached: {e}")
164 }
165}
166
167async fn answer(host: &str, what: &str, resp: reqwest::Response) -> Result<Value, String> {
169 let status = resp.status();
170 let remaining = resp
171 .headers()
172 .get("x-ratelimit-remaining")
173 .and_then(|v| v.to_str().ok())
174 .map(str::to_owned);
175 let reset = resp
176 .headers()
177 .get("x-ratelimit-reset")
178 .and_then(|v| v.to_str().ok())
179 .and_then(|v| v.parse::<u64>().ok());
180 let text = resp.text().await.unwrap_or_default();
181 let body: Value = if text.trim().is_empty() {
182 Value::Null
183 } else {
184 serde_json::from_str(&text).unwrap_or(Value::String(text))
185 };
186 if status.is_success() {
187 return Ok(body);
188 }
189 let says = body["message"].as_str().unwrap_or_default().to_owned();
190 Err(match status.as_u16() {
191 401 => format!("{host} refused the token (expired or revoked): `speccoder auth login`"),
192 403 | 429 if remaining.as_deref() == Some("0") => {
193 let now = std::time::SystemTime::now()
194 .duration_since(std::time::UNIX_EPOCH)
195 .map_or(0, |d| d.as_secs());
196 let wait = reset.map_or(60, |r| r.saturating_sub(now));
197 format!(
198 "{host}'s rate limit is used up; it resets in {} min",
199 wait.div_ceil(60).max(1)
200 )
201 }
202 403 => format!(
203 "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"
204 ),
205 404 if host == "github.com" => format!(
206 "{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: {}",
207 super::auth::APP_SLUG,
208 super::auth::install_url()
209 ),
210 404 => {
211 format!("{what} was not found on {host}: the repository, or the token cannot see it")
212 }
213 422 => {
214 let detail: Vec<String> = body["errors"]
215 .as_array()
216 .into_iter()
217 .flatten()
218 .filter_map(|e| e["message"].as_str().map(str::to_owned))
219 .collect();
220 if detail.is_empty() {
221 format!("GitHub refused it: {says}")
222 } else {
223 format!("GitHub refused it: {says} ({})", detail.join("; "))
224 }
225 }
226 code => format!("GitHub answered {code}: {says}"),
227 })
228}