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`
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
77pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
78    let format = config.format.with_default(" $icon $total.eng(w:1) ")?;
79
80    let mut interval = config.interval.timer();
81    let token = config
82        .token
83        .clone()
84        .or_else(|| std::env::var("I3RS_GITHUB_TOKEN").ok())
85        .error("Github token not found")?;
86
87    loop {
88        let stats = get_stats(&token).await?;
89
90        if stats.get("total").is_some_and(|x| *x > 0) || !config.hide_if_total_is_zero {
91            let mut widget = Widget::new().with_format(format.clone());
92
93            'outer: for (list_opt, ret) in [
94                (&config.critical, State::Critical),
95                (&config.warning, State::Warning),
96                (&config.info, State::Info),
97                (&config.good, State::Good),
98            ] {
99                if let Some(list) = list_opt {
100                    for val in list {
101                        if stats.get(val).is_some_and(|x| *x > 0) {
102                            widget.state = ret;
103                            break 'outer;
104                        }
105                    }
106                }
107            }
108
109            let mut values: HashMap<_, _> = stats
110                .into_iter()
111                .map(|(k, v)| (k.into(), Value::number(v)))
112                .collect();
113            values.insert("icon".into(), Value::icon("github"));
114            widget.set_values(values);
115
116            api.set_widget(widget)?;
117        } else {
118            api.hide()?;
119        }
120
121        select! {
122            _ = interval.tick() => (),
123            _ = api.wait_for_update_request() => (),
124        }
125    }
126}
127
128#[derive(Deserialize, Debug)]
129struct Notification {
130    reason: String,
131}
132
133async fn get_stats(token: &str) -> Result<HashMap<String, usize>> {
134    let mut stats = HashMap::new();
135    let mut total = 0;
136    for page in 1..100 {
137        let fetch = || get_on_page(token, page);
138        let on_page = fetch.retry(ExponentialBuilder::default()).await?;
139        if on_page.is_empty() {
140            break;
141        }
142        total += on_page.len();
143        for n in on_page {
144            stats.entry(n.reason).and_modify(|x| *x += 1).or_insert(1);
145        }
146    }
147    stats.insert("total".into(), total);
148    stats.entry("total".into()).or_insert(0);
149    stats.entry("assign".into()).or_insert(0);
150    stats.entry("author".into()).or_insert(0);
151    stats.entry("comment".into()).or_insert(0);
152    stats.entry("ci_activity".into()).or_insert(0);
153    stats.entry("invitation".into()).or_insert(0);
154    stats.entry("manual".into()).or_insert(0);
155    stats.entry("mention".into()).or_insert(0);
156    stats.entry("review_requested".into()).or_insert(0);
157    stats.entry("security_alert".into()).or_insert(0);
158    stats.entry("state_change".into()).or_insert(0);
159    stats.entry("subscribed".into()).or_insert(0);
160    stats.entry("team_mention".into()).or_insert(0);
161    Ok(stats)
162}
163
164async fn get_on_page(token: &str, page: usize) -> Result<Vec<Notification>> {
165    #[derive(Deserialize)]
166    #[serde(untagged)]
167    enum Response {
168        Notifications(Vec<Notification>),
169        ErrorMessage { message: String },
170    }
171
172    // https://docs.github.com/en/rest/reference/activity#notifications
173    let request = REQWEST_CLIENT
174        .get(format!(
175            "https://api.github.com/notifications?per_page=100&page={page}",
176        ))
177        .header("Authorization", format!("token {token}"));
178    let response = request
179        .send()
180        .await
181        .error("Failed to send request")?
182        .json::<Response>()
183        .await
184        .error("Failed to get JSON")?;
185
186    match response {
187        Response::Notifications(n) => Ok(n),
188        Response::ErrorMessage { message } => Err(Error::new(format!("API error: {message}"))),
189    }
190}