Skip to main content

sioul_core/
github.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! GitHub, for those who work there; off unless asked in the task settings
5//! (docs/github.md). Issues and pull requests that are yours (assigned to you,
6//! your review asked; what you opened or where you are mentioned when you ask
7//! for it) become tasks in a local list, "GitHub", never sent anywhere.
8//! GitHub's notification mail is tied to its task.
9//!
10//! What GitHub says is written into a task when GitHub changes it: its title,
11//! its link, and whether it is open (kept as `X-SIOUL-GITHUB`, so a task you
12//! marked done or dropped here stays so until GitHub's state changes). What
13//! you add here (a date, a length, steps, a project) stays.
14
15use crate::card::Card;
16use crate::lines;
17use crate::tasks::{self, Link};
18use jiff::Zoned;
19use serde::{Deserialize, Serialize};
20
21/// The list GitHub's tasks go to: kept on this computer only.
22pub const LIST_NAME: &str = "GitHub";
23pub const LIST_ID: &str = "github";
24const STATE: &str = "X-SIOUL-GITHUB";
25const REASON: &str = "X-SIOUL-GITHUB-REASON";
26
27/// An issue or a pull request, as GitHub lists it.
28#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Issue {
30    /// "owner/repo".
31    pub repo: String,
32    pub number: u64,
33    pub title: String,
34    /// Its page.
35    pub url: String,
36    pub pull: bool,
37    /// "open" or "closed".
38    pub state: String,
39    /// "completed", "not_planned", "duplicate", "reopened", or "".
40    pub state_reason: String,
41    /// RFC 3339, when closed.
42    pub closed_at: String,
43    /// RFC 3339, its last change.
44    pub updated_at: String,
45    /// Why it is yours: "assign", "review_requested", "author", "mention".
46    pub reason: String,
47}
48
49impl Issue {
50    /// What GitHub says of it, in one word: "open", "completed", "not_planned", "duplicate".
51    fn state_key(&self) -> &str {
52        match (self.state.as_str(), self.state_reason.as_str()) {
53            ("open", _) => "open",
54            (_, "not_planned" | "duplicate") => self.state_reason.as_str(),
55            _ => "completed",
56        }
57    }
58}
59
60/// How strongly an issue is yours: an assignment first, a mention last.
61pub fn rank(reason: &str) -> u8 {
62    match reason {
63        "assign" => 0,
64        "review_requested" => 1,
65        "author" => 2,
66        _ => 3,
67    }
68}
69
70/// The task's UID: "github:owner/repo#12".
71pub fn uid(repo: &str, number: u64) -> String {
72    format!("github:{repo}#{number}")
73}
74
75/// "github:owner/repo#12" → ("owner/repo", 12).
76pub fn parse_uid(uid: &str) -> Option<(String, u64)> {
77    let (repo, number) = uid.strip_prefix("github:")?.rsplit_once('#')?;
78    Some((repo.to_string(), number.parse().ok()?))
79}
80
81/// Why a task's issue was yours, as last written.
82pub fn reason_of(text: &str) -> String {
83    lines::unfold(text).iter().find(|l| lines::name(l) == REASON).map(|l| lines::value(l).trim().to_string()).unwrap_or_default()
84}
85
86/// The task's file: "github-owner-repo-12.ics".
87pub fn file_name(repo: &str, number: u64) -> String {
88    let repo: String = repo.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '-' }).collect();
89    format!("github-{repo}-{number}.ics")
90}
91
92/// The issue or pull request a GitHub notification mail is about, from its
93/// thread's root (In-Reply-To, else Message-ID): `<owner/repo/issues/12@github.com>`,
94/// `<owner/repo/pull/12/c345@github.com>`, `<owner/repo/issue/12/issue_event/6@github.com>`.
95pub fn thread_of(card: &Card) -> Option<(String, u64)> {
96    if card.from_address.as_deref() != Some("notifications@github.com") {
97        return None;
98    }
99    let root = card.headers.first("In-Reply-To").or(card.message_id.as_deref())?;
100    let id = root.trim().trim_start_matches('<').trim_end_matches('>');
101    let (path, host) = id.rsplit_once('@')?;
102    if host != "github.com" {
103        return None;
104    }
105    let parts: Vec<&str> = path.split('/').collect();
106    match parts.as_slice() {
107        [owner, repo, "issues" | "issue" | "pull", number, ..] => Some((format!("{owner}/{repo}"), number.parse().ok()?)),
108        _ => None,
109    }
110}
111
112/// The lines GitHub owns in a task, as it says them now.
113fn status_lines(issue: &Issue) -> Vec<String> {
114    let mut out = match issue.state_key() {
115        "open" => vec!["STATUS:NEEDS-ACTION".to_string()],
116        "not_planned" | "duplicate" => vec!["STATUS:CANCELLED".to_string()],
117        _ => vec!["STATUS:COMPLETED".to_string()],
118    };
119    if issue.state_key() == "completed"
120        && let Ok(at) = issue.closed_at.parse::<jiff::Timestamp>()
121    {
122        out.push(format!("COMPLETED:{}", at.strftime("%Y%m%dT%H%M%SZ")));
123    }
124    out.push(format!("{STATE}:{}", issue.state_key()));
125    out
126}
127
128fn link(issue: &Issue) -> String {
129    tasks::link_line(&Link { uri: issue.url.clone(), label: format!("{} #{}", issue.repo, issue.number), rel: "related".into() })
130}
131
132/// A new task for an issue, in `project` when one of your projects takes it.
133pub fn new_task(issue: &Issue, project: Option<&str>, now: &Zoned) -> String {
134    let stamp = now.timestamp().strftime("%Y%m%dT%H%M%SZ").to_string();
135    let mut out = vec!["BEGIN:VCALENDAR".to_string(), "VERSION:2.0".into(), "PRODID:-//Sioul//Sioul//EN".into(), "BEGIN:VTODO".into()];
136    out.push(format!("UID:{}", uid(&issue.repo, issue.number)));
137    out.push(format!("DTSTAMP:{stamp}"));
138    out.push(format!("CREATED:{stamp}"));
139    out.push(format!("SUMMARY:{}", lines::escape(&issue.title)));
140    out.extend(status_lines(issue));
141    out.push(format!("{REASON}:{}", issue.reason));
142    out.push(link(issue));
143    if let Some(project) = project {
144        out.push(format!("REFID:{}", lines::escape(project)));
145    }
146    out.extend(["END:VTODO".to_string(), "END:VCALENDAR".to_string()]);
147    lines::fold(&out)
148}
149
150/// A task's text with what GitHub changed written in; None when nothing
151/// changed. Its open or closed state only when GitHub's changed since it was
152/// last written: done or dropped here stays so.
153pub fn updated(text: &str, issue: &Issue, project: Option<&str>, now: &Zoned) -> Option<String> {
154    let source = lines::unfold(text);
155    let value_of = |name: &str| source.iter().find(|l| lines::name(l) == name).map(|l| lines::unescape(lines::value(l).trim()));
156    let mut dropped: Vec<&str> = Vec::new();
157    let mut added: Vec<String> = Vec::new();
158    if value_of("SUMMARY").as_deref() != Some(issue.title.as_str()) {
159        dropped.push("SUMMARY");
160        added.push(format!("SUMMARY:{}", lines::escape(&issue.title)));
161    }
162    if value_of(STATE).as_deref() != Some(issue.state_key()) {
163        dropped.extend(["STATUS", "COMPLETED", STATE]);
164        added.extend(status_lines(issue));
165    }
166    if value_of(REASON).as_deref() != Some(issue.reason.as_str()) {
167        dropped.push(REASON);
168        added.push(format!("{REASON}:{}", issue.reason));
169    }
170    let ours = |l: &str| lines::name(l) == "LINK" && lines::value(l).contains("://github.com/");
171    let link = link(issue);
172    let relinked = !source.iter().any(|l| *l == link);
173    if relinked {
174        added.push(link);
175    }
176    if let Some(project) = project
177        && !source.iter().any(|l| lines::name(l) == "REFID")
178    {
179        added.push(format!("REFID:{}", lines::escape(project)));
180    }
181    if added.is_empty() {
182        return None;
183    }
184    tasks::replace_lines(text, |l| dropped.contains(&lines::name(l).as_str()) || (relinked && ours(l)), added, now).ok()
185}
186
187/// GitHub no longer lists it as yours (unassigned, or closed before Sioul
188/// saw it): dropped here, unless already done or dropped. None when nothing changes.
189pub fn gone(text: &str, now: &Zoned) -> Option<String> {
190    let source = lines::unfold(text);
191    let state = source.iter().find(|l| lines::name(l) == STATE).map(|l| lines::value(l).trim().to_string());
192    if state.as_deref() != Some("open") {
193        return None;
194    }
195    let open = source.iter().find(|l| lines::name(l) == "STATUS").is_none_or(|l| matches!(lines::value(l).trim(), "NEEDS-ACTION" | "IN-PROCESS"));
196    let mut added = vec![format!("{STATE}:gone")];
197    if open {
198        added.push("STATUS:CANCELLED".into());
199    }
200    tasks::replace_lines(text, |l| lines::name(l) == STATE || (open && lines::name(l) == "STATUS"), added, now).ok()
201}
202
203/// What GitHub last said of a task: "open", "completed"…, "gone"; None for a task not from GitHub.
204pub fn state_of(text: &str) -> Option<String> {
205    lines::unfold(text).iter().find(|l| lines::name(l) == STATE).map(|l| lines::value(l).trim().to_string())
206}
207
208/// A mail-like card for an issue, for your projects' routes: from
209/// notifications@github.com, its subject as GitHub's mail writes it.
210pub fn as_card(issue: &Issue) -> Card {
211    let mut card = Card::from_bytes(b"From: GitHub <notifications@github.com>\r\nSubject: x\r\n\r\n").unwrap_or_else(|| unreachable!("a fixed message parses"));
212    card.subject = format!("[{}] {} ({} #{})", issue.repo, issue.title, if issue.pull { "PR" } else { "Issue" }, issue.number);
213    card
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn issue(state: &str, reason: &str) -> Issue {
221        Issue {
222            repo: "example/project".into(),
223            number: 12,
224            title: "Crash when exporting".into(),
225            url: "https://github.com/example/project/issues/12".into(),
226            state: state.into(),
227            state_reason: reason.into(),
228            closed_at: if state == "closed" { "2026-10-03T10:00:00Z".into() } else { String::new() },
229            reason: "assign".into(),
230            ..Issue::default()
231        }
232    }
233
234    #[test]
235    fn a_mail_finds_its_issue() {
236        let mail = |headers: &str| Card::from_bytes(format!("From: Someone <notifications@github.com>\r\n{headers}Subject: Re: x\r\n\r\nbody").as_bytes()).unwrap();
237        assert_eq!(thread_of(&mail("Message-ID: <example/project/issues/12@github.com>\r\n")), Some(("example/project".into(), 12)));
238        assert_eq!(thread_of(&mail("Message-ID: <o/r/pull/7/c99@github.com>\r\nIn-Reply-To: <o/r/pull/7@github.com>\r\n")), Some(("o/r".into(), 7)));
239        assert_eq!(thread_of(&mail("Message-ID: <o/r/issue/5/issue_event/1@github.com>\r\n")), Some(("o/r".into(), 5)));
240        assert_eq!(thread_of(&mail("Message-ID: <o/r/commit/abc/1@github.com>\r\n")), None);
241        let other = Card::from_bytes(b"From: x@example.org\r\nMessage-ID: <o/r/issues/1@github.com>\r\n\r\n").unwrap();
242        assert_eq!(thread_of(&other), None);
243    }
244
245    #[test]
246    fn github_changes_what_it_owns_only() {
247        let now = Zoned::now();
248        let zone = jiff::tz::TimeZone::UTC;
249        let text = new_task(&issue("open", ""), Some("project"), &now);
250        let task = tasks::task_of_text(&text, &zone).unwrap();
251        assert_eq!((task.uid.as_str(), task.status, task.projects.clone()), ("github:example/project#12", tasks::Status::NeedsAction, vec!["project".to_string()]));
252        assert_eq!(task.links[0].uri, "https://github.com/example/project/issues/12");
253        // Nothing changed there: nothing written.
254        assert_eq!(updated(&text, &issue("open", ""), Some("project"), &now), None);
255        // Done here while still open there: stays done.
256        let done = tasks::set_status(&text, tasks::Status::Completed, &zone, &now).unwrap();
257        assert_eq!(updated(&done, &issue("open", ""), None, &now), None);
258        // Closed as not planned there: dropped here.
259        let closed = updated(&done, &issue("closed", "not_planned"), None, &now).unwrap();
260        assert_eq!(tasks::task_of_text(&closed, &zone).unwrap().status, tasks::Status::Cancelled);
261        // Reopened there: open here again; a new title too.
262        let mut again = issue("open", "reopened");
263        again.title = "Crash when exporting to TIFF".into();
264        let reopened = tasks::task_of_text(&updated(&closed, &again, None, &now).unwrap(), &zone).unwrap();
265        assert_eq!((reopened.status, reopened.title.as_str()), (tasks::Status::NeedsAction, "Crash when exporting to TIFF"));
266        // No longer yours: dropped, once.
267        let left = gone(&text, &now).unwrap();
268        assert_eq!(tasks::task_of_text(&left, &zone).unwrap().status, tasks::Status::Cancelled);
269        assert_eq!(gone(&left, &now), None);
270        assert_eq!(as_card(&issue("open", "")).subject, "[example/project] Crash when exporting (Issue #12)");
271        assert_eq!(parse_uid("github:example/project#12"), Some(("example/project".into(), 12)));
272        assert_eq!(reason_of(&text), "assign");
273    }
274}