1use crate::card::Card;
16use crate::lines;
17use crate::tasks::{self, Link};
18use jiff::Zoned;
19use serde::{Deserialize, Serialize};
20
21pub 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
29pub struct Issue {
30 pub repo: String,
32 pub number: u64,
33 pub title: String,
34 pub url: String,
36 pub pull: bool,
37 pub state: String,
39 pub state_reason: String,
41 pub closed_at: String,
43 pub updated_at: String,
45 pub reason: String,
47}
48
49impl Issue {
50 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
60pub fn rank(reason: &str) -> u8 {
62 match reason {
63 "assign" => 0,
64 "review_requested" => 1,
65 "author" => 2,
66 _ => 3,
67 }
68}
69
70pub fn uid(repo: &str, number: u64) -> String {
72 format!("github:{repo}#{number}")
73}
74
75pub 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
81pub 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
86pub 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
92pub 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
112fn 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
132pub 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
150pub 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
187pub 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
203pub 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
208pub 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 assert_eq!(updated(&text, &issue("open", ""), Some("project"), &now), None);
255 let done = tasks::set_status(&text, tasks::Status::Completed, &zone, &now).unwrap();
257 assert_eq!(updated(&done, &issue("open", ""), None, &now), None);
258 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 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 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}