Skip to main content

sioul_app/
github.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! GitHub, once asked in the task settings (docs/github.md): the issues and
5//! pull requests that are yours, in the local "GitHub" list every thirty
6//! minutes, each in the project whose routes take it; GitHub's notification
7//! mail tied to its task and that task's project.
8
9use crate::backend::{QtThread, Shared, load_config, set_status, tr};
10use jiff::Zoned;
11use jiff::tz::TimeZone;
12use sioul_core::projects::ProjectStore;
13use sioul_core::config::Config;
14use sioul_core::github::{self, Issue};
15use sioul_core::links::{self, LocalLinks};
16use sioul_core::trust::Trust;
17use sioul_core::vdir::{self, Collection, Kind};
18use sioul_core::{porch, tasks};
19use sioul_sync::github::Wanted;
20use sioul_sync::{Control, SyncError};
21use std::collections::BTreeSet;
22use std::path::PathBuf;
23use std::sync::Arc;
24use std::time::Duration;
25
26const EVERY: Duration = Duration::from_secs(30 * 60);
27/// GitHub's search can take a while to see a change: an issue it stopped
28/// finding is let go only when nothing changed on it for this long.
29const SETTLED: i64 = 3600;
30
31/// Starts bringing GitHub's issues when asked; brings them now when running.
32pub(crate) fn start(qt: &QtThread, shared: &Arc<Shared>) {
33    if !load_config().github.enabled || crate::backend::offline() {
34        return;
35    }
36    let control = Arc::new(Control::default());
37    {
38        let Ok(mut running) = shared.github.lock() else { return };
39        if let Some(running) = running.as_ref() {
40            running.retry();
41            return;
42        }
43        *running = Some(Arc::clone(&control));
44    }
45    let (qt, shared) = (qt.clone(), Arc::clone(shared));
46    std::thread::spawn(move || {
47        while !control.stopped() {
48            let config = load_config();
49            if !config.github.enabled {
50                break;
51            }
52            match bring(&config) {
53                Ok(0) => {}
54                Ok(_) => crate::work::show_work(&qt, &shared),
55                Err(e) => {
56                    set_status(&qt, e.sentence(tr(), "GitHub"));
57                    // A lasting error (a token refused): waits for a nudge, never ends.
58                    if e.is_lasting() && !control.park() {
59                        break;
60                    }
61                }
62            }
63            control.pause(EVERY);
64        }
65        if let Ok(mut running) = shared.github.lock()
66            && running.as_ref().is_some_and(|c| Arc::ptr_eq(c, &control))
67        {
68            *running = None;
69        }
70    });
71}
72
73/// Stops it: turned off.
74pub(crate) fn stop(shared: &Shared) {
75    if let Some(control) = shared.github.lock().ok().and_then(|mut running| running.take()) {
76        control.stop();
77    }
78}
79
80/// The "GitHub" list, kept on this computer only.
81fn list() -> Option<Collection> {
82    vdir::collections(Kind::Calendars).into_iter().find(|c| c.account == vdir::LOCAL && c.id == github::LIST_ID)
83}
84
85/// Brings GitHub's open issues that are yours into the list, and lets go of
86/// those it no longer lists: closed, or no longer yours. Returns how many
87/// tasks changed.
88fn bring(config: &Config) -> Result<usize, SyncError> {
89    let list = match list() {
90        Some(list) => list,
91        None => vdir::create(Kind::Calendars, vdir::LOCAL, github::LIST_NAME, None, &["VTODO"]).map_err(SyncError::Disk)?,
92    };
93    let wanted = Wanted { assigned: config.github.assigned, reviews: config.github.reviews, created: config.github.created, mentioned: config.github.mentioned };
94    let open = sioul_sync::github::open_issues(wanted)?;
95    // Your projects' routes say which project an issue belongs to, as they do for mail.
96    let store = config.notes_root_path().and_then(|root| ProjectStore::load(&root).ok()).map(|s| s.with_ties(&LocalLinks::load(&LocalLinks::default_path())));
97    let project_of = |issue: &Issue| store.as_ref().and_then(|s| s.route(&github::as_card(issue)).first().map(|r| r.project.id.clone()));
98    let now = Zoned::now();
99    let mut changed = 0;
100    let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
101    for issue in &open {
102        let path = list.dir.join(github::file_name(&issue.repo, issue.number));
103        seen.insert(path.clone());
104        let text = match std::fs::read_to_string(&path) {
105            Ok(before) => github::updated(&before, issue, project_of(issue).as_deref(), &now),
106            Err(_) => Some(github::new_task(issue, project_of(issue).as_deref(), &now)),
107        };
108        if let Some(text) = text {
109            vdir::write_item(&path, &text).map_err(SyncError::Disk)?;
110            changed += 1;
111        }
112    }
113    for path in list.items().into_iter().filter(|p| !seen.contains(p)) {
114        let Ok(before) = std::fs::read_to_string(&path) else { continue };
115        if github::state_of(&before).as_deref() != Some("open") {
116            continue;
117        }
118        let Some((repo, number)) = tasks::task_of_text(&before, &TimeZone::system()).and_then(|t| github::parse_uid(&t.uid)) else { continue };
119        let text = match sioul_sync::github::issue(&repo, number, &github::reason_of(&before))? {
120            Some(issue) if issue.state == "closed" => github::updated(&before, &issue, None, &now),
121            // Still open, and changed a moment ago: the search may not see it yet.
122            Some(issue) if issue.updated_at.parse::<jiff::Timestamp>().is_ok_and(|at| now.timestamp().as_second() - at.as_second() < SETTLED) => None,
123            _ => github::gone(&before, &now),
124        };
125        if let Some(text) = text {
126            vdir::write_item(&path, &text).map_err(SyncError::Disk)?;
127            changed += 1;
128        }
129    }
130    Ok(changed)
131}
132
133/// GitHub's notification mail, tied to its issue's task, and to that task's
134/// project. Mail about an issue that is not yours makes nothing; nor does a
135/// message that only claims to come from GitHub: its signature must say so,
136/// as the Porch judges it, or anyone could slip a message into a project.
137pub(crate) fn tie_mail(config: &Config, files: &[PathBuf], ties: &mut LocalLinks) {
138    if !config.github.enabled {
139        return;
140    }
141    let Some(list) = list() else { return };
142    let senders = porch::Senders::load(config);
143    let judged = porch::judge(files, &config.mail_sources(), None, &porch::KnownSenders::default(), &senders, Zoned::now().timestamp().as_second());
144    for card in judged.into_iter().filter(|t| t.trust == Trust::Verified).map(|t| t.card) {
145        let (Some((repo, number)), Some(mid)) = (github::thread_of(&card), card.message_id.as_deref()) else { continue };
146        let Ok(text) = std::fs::read_to_string(list.dir.join(github::file_name(&repo, number))) else { continue };
147        let Some(task) = tasks::task_of_text(&text, &TimeZone::system()) else { continue };
148        ties.add(&links::mail_uri(mid), &links::task_uri(&task.uid), "link");
149        if let Some(project) = task.projects.first() {
150            ties.add(&links::mail_uri(mid), &links::project_uri(project), "project");
151        }
152    }
153}