Skip to main content

i3status_rs/blocks/
github.rs

1//! The number of GitHub notifications
2//!
3//! This block shows the unread notification count for a GitHub account. A GitHub [personal access token](https://github.com/settings/tokens/new) with the "notifications" scope is required, and must be passed using the `I3RS_GITHUB_TOKEN` environment variable or `token` configuration option. Optionally the colour of the block is determined by the highest notification in the following lists from highest to lowest: `critical`,`warning`,`info`,`good`
4//!
5//! # Configuration
6//!
7//! Key | Values | Default
8//! ----|--------|--------
9//! `format` | A string to customise the output of this block. See below for available placeholders. | `" $icon $total.eng(w:1) "`
10//! `interval` | Update interval in seconds | `30`
11//! `token` | A GitHub personal access token with the "notifications" scope | `None`
12//! `hide_if_total_is_zero` | Hide this block if the total count of notifications is zero | `false`
13//! `critical` | List of notification types that change the block to the critical colour | `None`
14//! `warning` | List of notification types that change the block to the warning colour | `None`
15//! `info` | List of notification types that change the block to the info colour | `None`
16//! `good` | List of notification types that change the block to the good colour | `None`
17//!
18//!
19//! All the placeholders are numbers without a unit.
20//!
21//! Placeholder        | Value
22//! -------------------|------
23//! `icon`             | A static icon
24//! `total`            | The total number of notifications
25//! `assign`           | You were assigned to the issue
26//! `author`           | You created the thread
27//! `comment`          | You commented on the thread
28//! `ci_activity`      | A GitHub Actions workflow run that you triggered was completed
29//! `invitation`       | You accepted an invitation to contribute to the repository
30//! `manual`           | You subscribed to the thread (via an issue or pull request)
31//! `mention`          | You were specifically @mentioned in the content
32//! `review_requested` | You, or a team you're a member of, were requested to review a pull request
33//! `security_alert`   | GitHub discovered a security vulnerability in your repository
34//! `state_change`     | You changed the thread state (for example, closing an issue or merging a pull request)
35//! `subscribed`       | You're watching the repository
36//! `team_mention`     | You were on a team that was mentioned
37//!
38//! # Examples
39//!
40//! ```toml
41//! [[block]]
42//! block = "github"
43//! format = " $icon $total.eng(w:1)|$mention.eng(w:1) "
44//! interval = 60
45//! token = "..."
46//! ```
47//!
48//! ```toml
49//! [[block]]
50//! block = "github"
51//! token = "..."
52//! format = " $icon $total.eng(w:1) "
53//! info = ["total"]
54//! warning = ["mention","review_requested"]
55//! hide_if_total_is_zero = true
56//! ```
57//!
58//! # Icons Used
59//! - `github` (`$icon`)
60
61use super::prelude::*;
62
63#[derive(Deserialize, Debug, SmartDefault)]
64#[serde(deny_unknown_fields, default)]
65pub struct Config {
66    #[default(60.into())]
67    pub interval: Seconds,
68    pub format: FormatConfig,
69    pub token: Option<String>,
70    pub hide_if_total_is_zero: bool,
71    pub good: Option<Vec<String>>,
72    pub info: Option<Vec<String>>,
73    pub warning: Option<Vec<String>>,
74    pub critical: Option<Vec<String>>,
75}
76
77/// Stat placeholders [`get_stats`] inserts on every fetch (defaulting to
78/// zero), and which the plan therefore guarantees on every render.
79const STAT_KEYS: [&str; 13] = [
80    "total",
81    "assign",
82    "author",
83    "comment",
84    "ci_activity",
85    "invitation",
86    "manual",
87    "mention",
88    "review_requested",
89    "security_alert",
90    "state_change",
91    "subscribed",
92    "team_mention",
93];
94
95pub(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
96    let main = OutputPlan::new(
97        "main",
98        config.format.with_default(" $icon $total.eng(w:1) ")?,
99    )
100    .icon("icon", IconChoices::one(icons::GITHUB));
101    BlockPlan::new(vec![main])
102}
103
104pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
105    let output_main = plan.output("main")?;
106
107    let mut interval = config.interval.timer();
108    let token = config
109        .token
110        .clone()
111        .or_else(|| std::env::var("I3RS_GITHUB_TOKEN").ok())
112        .error("Github token not found")?;
113
114    loop {
115        let stats = get_stats(&token).await?;
116
117        if stats.get("total").is_some_and(|x| *x > 0) || !config.hide_if_total_is_zero {
118            let mut widget = output_main.new_widget();
119
120            'outer: for (list_opt, ret) in [
121                (&config.critical, State::Critical),
122                (&config.warning, State::Warning),
123                (&config.info, State::Info),
124                (&config.good, State::Good),
125            ] {
126                if let Some(list) = list_opt {
127                    for val in list {
128                        if stats.get(val).is_some_and(|x| *x > 0) {
129                            widget.state = ret;
130                            break 'outer;
131                        }
132                    }
133                }
134            }
135
136            let mut values: HashMap<_, _> = stats
137                .into_iter()
138                .map(|(k, v)| (k.into(), Value::number(v)))
139                .collect();
140            values.insert("icon".into(), Value::icon(icons::GITHUB));
141            widget.set_values(values);
142
143            api.set_widget(widget)?;
144        } else {
145            api.hide()?;
146        }
147
148        select! {
149            _ = interval.tick() => (),
150            _ = api.wait_for_update_request() => (),
151        }
152    }
153}
154
155#[derive(Deserialize, Debug)]
156struct Notification {
157    reason: String,
158}
159
160async fn get_stats(token: &str) -> Result<HashMap<String, usize>> {
161    let mut stats = HashMap::new();
162    let mut total = 0;
163    for page in 1..100 {
164        let fetch = || get_on_page(token, page);
165        let on_page = fetch.retry(ExponentialBuilder::default()).await?;
166        if on_page.is_empty() {
167            break;
168        }
169        total += on_page.len();
170        for n in on_page {
171            stats.entry(n.reason).and_modify(|x| *x += 1).or_insert(1);
172        }
173    }
174    stats.insert("total".into(), total);
175    // Default every declared stat to zero so the plan's value guarantees
176    // hold on every render.
177    for key in STAT_KEYS {
178        stats.entry(key.into()).or_insert(0);
179    }
180    Ok(stats)
181}
182
183async fn get_on_page(token: &str, page: usize) -> Result<Vec<Notification>> {
184    #[derive(Deserialize)]
185    #[serde(untagged)]
186    enum Response {
187        Notifications(Vec<Notification>),
188        ErrorMessage { message: String },
189    }
190
191    // https://docs.github.com/en/rest/reference/activity#notifications
192    let request = REQWEST_CLIENT
193        .get(format!(
194            "https://api.github.com/notifications?per_page=100&page={page}",
195        ))
196        .header("Authorization", format!("token {token}"));
197    let response = request
198        .send()
199        .await
200        .error("Failed to send request")?
201        .json::<Response>()
202        .await
203        .error("Failed to get JSON")?;
204
205    match response {
206        Response::Notifications(n) => Ok(n),
207        Response::ErrorMessage { message } => Err(Error::new(format!("API error: {message}"))),
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn plan_declares_main_with_github_icon() {
217        let plan = prepare(&Config::default()).unwrap();
218        assert_eq!(plan.outputs().count(), 1);
219        let main = plan.output("main").unwrap();
220        assert_eq!(main.single_icon("icon").unwrap(), "github");
221        assert!(main.format().contains_key("total"));
222    }
223
224    #[test]
225    fn custom_format_is_used() {
226        let config = Config {
227            format: " $icon $mention.eng(w:1) ".parse().unwrap(),
228            ..Config::default()
229        };
230        let plan = prepare(&config).unwrap();
231        let main = plan.output("main").unwrap();
232        assert!(main.format().contains_key("mention"));
233        assert!(!main.format().contains_key("total"));
234    }
235}