1use crate::SyncError;
11use serde_json::Value;
12use sioul_core::config::state_dir;
13use sioul_core::github::Issue;
14use std::collections::BTreeMap;
15use std::path::PathBuf;
16use std::time::Duration;
17
18const API: &str = "https://api.github.com";
19const KEYRING: &str = "GitHub token";
20const VERSION: &str = "2022-11-28";
22pub const TOKEN_REFUSED: &str = "github-token";
24
25pub fn token() -> Option<String> {
27 #[cfg(feature = "insecure-test-tls")]
29 if let Some(token) = std::env::var_os("SIOUL_TEST_GITHUB_TOKEN") {
30 return Some(token.to_string_lossy().to_string());
31 }
32 crate::secret::named(KEYRING).filter(|t| !t.trim().is_empty())
33}
34
35fn api() -> String {
36 #[cfg(feature = "insecure-test-tls")]
37 if let Some(base) = std::env::var_os("SIOUL_TEST_GITHUB") {
38 return base.to_string_lossy().trim_end_matches('/').to_string();
39 }
40 API.to_string()
41}
42
43pub fn save_token(token: &str) -> Result<(), SyncError> {
45 if token.trim().is_empty() { crate::secret::forget_named(KEYRING) } else { crate::secret::save_named(KEYRING, token.trim()) }
46}
47
48#[derive(Debug, Clone, Copy)]
50pub struct Wanted {
51 pub assigned: bool,
52 pub reviews: bool,
53 pub created: bool,
54 pub mentioned: bool,
55}
56
57impl Wanted {
58 fn searches(self) -> Vec<(&'static str, &'static str)> {
63 [
64 (self.assigned, "is:open is:issue assignee:@me archived:false", "assign"),
65 (self.assigned, "is:open is:pr assignee:@me archived:false", "assign"),
66 (self.reviews, "is:open is:pr user-review-requested:@me archived:false", "review_requested"),
67 (self.created, "is:open is:issue author:@me archived:false", "author"),
68 (self.created, "is:open is:pr author:@me archived:false", "author"),
69 (self.mentioned, "is:open is:issue mentions:@me archived:false", "mention"),
70 (self.mentioned, "is:open is:pr mentions:@me archived:false", "mention"),
71 ]
72 .into_iter()
73 .filter(|(on, _, _)| *on)
74 .map(|(_, query, reason)| (query, reason))
75 .collect()
76 }
77}
78
79#[derive(Default, serde::Serialize, serde::Deserialize)]
81struct Cache {
82 answers: BTreeMap<String, (String, Value)>,
83}
84
85fn cache_path() -> PathBuf {
86 state_dir().join("github-cache.json")
87}
88
89impl Cache {
90 fn load() -> Cache {
91 std::fs::read_to_string(cache_path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
92 }
93
94 fn save(&self) {
95 if let Ok(text) = serde_json::to_string(self) {
96 let _ = std::fs::create_dir_all(state_dir());
97 let _ = std::fs::write(cache_path(), text);
98 }
99 }
100}
101
102struct Client {
103 agent: ureq::Agent,
104 token: String,
105 cache: Cache,
106}
107
108impl Client {
109 fn new() -> Result<Client, SyncError> {
110 let token = token().ok_or(SyncError::NoPassword)?;
111 let agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(30))).http_status_as_error(false).build().into();
112 Ok(Client { agent, token, cache: Cache::load() })
113 }
114
115 fn get(&mut self, path: &str) -> Result<Option<Value>, SyncError> {
117 let url = format!("{}{path}", api());
118 let mut request = self
119 .agent
120 .get(&url)
121 .header("Authorization", &format!("Bearer {}", self.token))
122 .header("Accept", "application/vnd.github+json")
123 .header("X-GitHub-Api-Version", VERSION)
124 .header("User-Agent", "Sioul");
125 if let Some((etag, _)) = self.cache.answers.get(&url) {
126 request = request.header("If-None-Match", etag);
127 }
128 let mut response = request.call().map_err(|e| SyncError::Network(e.to_string()))?;
129 let status = response.status().as_u16();
130 let header = |name: &str| response.headers().get(name).and_then(|v| v.to_str().ok()).map(str::to_string);
131 let (etag, remaining, reset) = (header("ETag"), header("x-ratelimit-remaining"), header("x-ratelimit-reset"));
132 match status {
133 304 => Ok(self.cache.answers.get(&url).map(|(_, body)| body.clone())),
134 200 => {
135 let body: Value = serde_json::from_str(&response.body_mut().with_config().limit(16 * 1024 * 1024).read_to_string().unwrap_or_default()).unwrap_or(Value::Null);
136 if let Some(etag) = etag {
137 self.cache.answers.insert(url, (etag, body.clone()));
138 }
139 Ok(Some(body))
140 }
141 404 | 410 => Ok(None),
142 401 => Err(SyncError::Login(TOKEN_REFUSED.into())),
143 403 | 429 if remaining.as_deref() == Some("0") || status == 429 => {
145 let until = reset.and_then(|r| r.parse::<i64>().ok()).and_then(|r| jiff::Timestamp::from_second(r).ok()).map(|t| t.to_zoned(jiff::tz::TimeZone::system()).strftime("%H:%M").to_string());
146 Err(SyncError::Server(format!("GitHub: {status}, wait until {}", until.unwrap_or_else(|| "later".into()))))
147 }
148 status => {
151 let body: Value = serde_json::from_str(&response.body_mut().with_config().limit(64 * 1024).read_to_string().unwrap_or_default()).unwrap_or(Value::Null);
152 let said = body["errors"][0]["message"].as_str().or(body["message"].as_str()).unwrap_or("").to_string();
153 let what = path.split('?').next().unwrap_or(path);
154 Err(SyncError::Server(if said.is_empty() { format!("GitHub: {status} {what}") } else { format!("GitHub: {status} {what}: {said}") }))
155 }
156 }
157 }
158
159 fn search(&mut self, query: &str) -> Result<Vec<Value>, SyncError> {
161 let mut items = Vec::new();
162 for page in 1..=10 {
163 let Some(answer) = self.get(&format!("/search/issues?q={}&per_page=100&page={page}", crate::google::encode(query)))? else { break };
164 let found = answer["items"].as_array().cloned().unwrap_or_default();
165 let count = found.len();
166 items.extend(found);
167 if count < 100 {
168 break;
169 }
170 }
171 Ok(items)
172 }
173}
174
175fn issue_of(item: &Value, reason: &str) -> Option<Issue> {
177 let repo = item["repository_url"].as_str()?.rsplit("/repos/").next()?.to_string();
178 let text = |key: &str| item[key].as_str().unwrap_or("").to_string();
179 Some(Issue {
180 repo,
181 number: item["number"].as_u64()?,
182 title: text("title"),
183 url: text("html_url"),
184 pull: item["pull_request"].is_object(),
185 state: text("state"),
186 state_reason: text("state_reason"),
187 closed_at: text("closed_at"),
188 updated_at: text("updated_at"),
189 reason: reason.to_string(),
190 })
191}
192
193pub fn open_issues(wanted: Wanted) -> Result<Vec<Issue>, SyncError> {
196 let mut client = Client::new()?;
197 let mut found: BTreeMap<(String, u64), Issue> = BTreeMap::new();
198 for (query, reason) in wanted.searches() {
199 for issue in client.search(query)?.iter().filter_map(|item| issue_of(item, reason)) {
200 let key = (issue.repo.clone(), issue.number);
201 if found.get(&key).is_none_or(|known| sioul_core::github::rank(reason) < sioul_core::github::rank(&known.reason)) {
202 found.insert(key, issue);
203 }
204 }
205 }
206 client.cache.save();
207 Ok(found.into_values().collect())
208}
209
210pub fn issue(repo: &str, number: u64, reason: &str) -> Result<Option<Issue>, SyncError> {
212 let mut client = Client::new()?;
213 let answer = client.get(&format!("/repos/{repo}/issues/{number}"))?;
214 client.cache.save();
215 Ok(answer.as_ref().and_then(|item| issue_of(item, reason)))
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn issues_from_github() {
224 let item = serde_json::json!({ "repository_url": "https://api.github.com/repos/someone/project", "number": 12, "title": "Crash", "html_url": "https://github.com/someone/project/pull/12",
225 "pull_request": { "url": "…" }, "state": "closed", "state_reason": null, "closed_at": "2026-10-03T10:00:00Z" });
226 let issue = issue_of(&item, "review_requested").unwrap();
227 assert_eq!((issue.repo.as_str(), issue.number, issue.pull, issue.state_reason.as_str()), ("someone/project", 12, true, ""));
228 let wanted = Wanted { assigned: true, reviews: true, created: false, mentioned: false };
229 assert_eq!(wanted.searches().len(), 3);
231 }
232
233 #[test]
234 fn every_search_says_issues_or_pull_requests() {
235 let all = Wanted { assigned: true, reviews: true, created: true, mentioned: true };
237 let searches = all.searches();
238 assert_eq!(searches.len(), 7);
239 for (query, _) in &searches {
240 assert!(query.contains("is:issue") || query.contains("is:pr"), "{query}");
241 assert!(!(query.contains("is:issue") && query.contains("is:pr")), "{query}");
242 }
243 }
244}