Skip to main content

sioul_sync/
github.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! GitHub's REST API, read only, with your fine-grained token from the
5//! keyring (docs/github.md): the open issues and pull requests that are yours,
6//! found by search across GitHub (assigned to you, your review asked, opened
7//! by you, mentioning you), and one issue by its number. Answers are asked
8//! with their ETag: an unchanged one costs nothing and comes from the cache.
9
10use 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";
20/// Supported until March 2028.
21const VERSION: &str = "2022-11-28";
22/// What a refused token says (`SyncError::Login`).
23pub const TOKEN_REFUSED: &str = "github-token";
24
25/// The token kept in the keyring.
26pub fn token() -> Option<String> {
27    // A local stand-in for GitHub, in tests only (never in Sioul's builds).
28    #[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
43/// Keeps the token in the keyring; an empty one is forgotten.
44pub 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/// What to bring, from the settings.
49#[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    /// The searches, with why what they find is yours. Issues and pull
59    /// requests are asked apart: with a fine-grained token or an app's user
60    /// token, GitHub refuses a search that would mix them (422, "Query must
61    /// include 'is:issue' or 'is:pull-request'").
62    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/// Answers kept with their ETag, by address.
80#[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    /// One answer, from the cache when GitHub says it did not change.
116    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            // Too many requests: GitHub says until when.
144            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            // What GitHub says of a refused request ("Validation Failed", the
149            // query's fault), said with it rather than the bare number.
150            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    /// Every page of a search (100 a page, 1,000 at most).
160    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
175/// An issue from GitHub's JSON; `reason`, why it is yours.
176fn 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
193/// The open issues and pull requests that are yours, each once, with its
194/// strongest reason (an assignment before a review asked, before a mention).
195pub 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
210/// One issue by its number, as it is now; None when GitHub no longer shows it to you.
211pub 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        // Assigned: issues and pull requests apart; reviews: pull requests only.
230        assert_eq!(wanted.searches().len(), 3);
231    }
232
233    #[test]
234    fn every_search_says_issues_or_pull_requests() {
235        // GitHub refuses (422) a search mixing both, with a fine-grained token.
236        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}