i3status_rs/blocks/
github.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
//! The number of GitHub notifications
//!
//! 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`
//!
//! # Configuration
//!
//! Key | Values | Default
//! ----|--------|--------
//! `format` | A string to customise the output of this block. See below for available placeholders. | `" $icon $total.eng(w:1) "`
//! `interval` | Update interval in seconds | `30`
//! `token` | A GitHub personal access token with the "notifications" scope | `None`
//! `hide_if_total_is_zero` | Hide this block if the total count of notifications is zero | `false`
//! `critical` | List of notification types that change the block to the critical colour | `None`
//! `warning` | List of notification types that change the block to the warning colour | `None`
//! `info` | List of notification types that change the block to the info colour | `None`
//! `good` | List of notification types that change the block to the good colour | `None`
//!
//!
//! All the placeholders are numbers without a unit.
//!
//! Placeholder        | Value
//! -------------------|------
//! `icon`             | A static icon
//! `total`            | The total number of notifications
//! `assign`           | You were assigned to the issue
//! `author`           | You created the thread
//! `comment`          | You commented on the thread
//! `ci_activity`      | A GitHub Actions workflow run that you triggered was completed
//! `invitation`       | You accepted an invitation to contribute to the repository
//! `manual`           | You subscribed to the thread (via an issue or pull request)
//! `mention`          | You were specifically @mentioned in the content
//! `review_requested` | You, or a team you're a member of, were requested to review a pull request
//! `security_alert`   | GitHub discovered a security vulnerability in your repository
//! `state_change`     | You changed the thread state (for example, closing an issue or merging a pull request)
//! `subscribed`       | You're watching the repository
//! `team_mention`     | You were on a team that was mentioned
//!
//! # Examples
//!
//! ```toml
//! [[block]]
//! block = "github"
//! format = " $icon $total.eng(w:1)|$mention.eng(w:1) "
//! interval = 60
//! token = "..."
//! ```
//!
//! ```toml
//! [[block]]
//! block = "github"
//! token = "..."
//! format = " $icon $total.eng(w:1) "
//! info = ["total"]
//! warning = ["mention","review_requested"]
//! hide_if_total_is_zero = true
//! ```
//!
//! # Icons Used
//! - `github`

use super::prelude::*;

#[derive(Deserialize, Debug, SmartDefault)]
#[serde(deny_unknown_fields, default)]
pub struct Config {
    #[default(60.into())]
    pub interval: Seconds,
    pub format: FormatConfig,
    pub token: Option<String>,
    pub hide_if_total_is_zero: bool,
    pub good: Option<Vec<String>>,
    pub info: Option<Vec<String>>,
    pub warning: Option<Vec<String>>,
    pub critical: Option<Vec<String>>,
}

pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
    let format = config.format.with_default(" $icon $total.eng(w:1) ")?;

    let mut interval = config.interval.timer();
    let token = config
        .token
        .clone()
        .or_else(|| std::env::var("I3RS_GITHUB_TOKEN").ok())
        .error("Github token not found")?;

    loop {
        let stats = get_stats(&token).await?;

        if stats.get("total").is_some_and(|x| *x > 0) || !config.hide_if_total_is_zero {
            let mut widget = Widget::new().with_format(format.clone());

            'outer: for (list_opt, ret) in [
                (&config.critical, State::Critical),
                (&config.warning, State::Warning),
                (&config.info, State::Info),
                (&config.good, State::Good),
            ] {
                if let Some(list) = list_opt {
                    for val in list {
                        if stats.get(val).is_some_and(|x| *x > 0) {
                            widget.state = ret;
                            break 'outer;
                        }
                    }
                }
            }

            let mut values: HashMap<_, _> = stats
                .into_iter()
                .map(|(k, v)| (k.into(), Value::number(v)))
                .collect();
            values.insert("icon".into(), Value::icon("github"));
            widget.set_values(values);

            api.set_widget(widget)?;
        } else {
            api.hide()?;
        }

        select! {
            _ = interval.tick() => (),
            _ = api.wait_for_update_request() => (),
        }
    }
}

#[derive(Deserialize, Debug)]
struct Notification {
    reason: String,
}

async fn get_stats(token: &str) -> Result<HashMap<String, usize>> {
    let mut stats = HashMap::new();
    let mut total = 0;
    for page in 1..100 {
        let fetch = || get_on_page(token, page);
        let on_page = fetch.retry(ExponentialBuilder::default()).await?;
        if on_page.is_empty() {
            break;
        }
        total += on_page.len();
        for n in on_page {
            stats.entry(n.reason).and_modify(|x| *x += 1).or_insert(1);
        }
    }
    stats.insert("total".into(), total);
    stats.entry("total".into()).or_insert(0);
    stats.entry("assign".into()).or_insert(0);
    stats.entry("author".into()).or_insert(0);
    stats.entry("comment".into()).or_insert(0);
    stats.entry("ci_activity".into()).or_insert(0);
    stats.entry("invitation".into()).or_insert(0);
    stats.entry("manual".into()).or_insert(0);
    stats.entry("mention".into()).or_insert(0);
    stats.entry("review_requested".into()).or_insert(0);
    stats.entry("security_alert".into()).or_insert(0);
    stats.entry("state_change".into()).or_insert(0);
    stats.entry("subscribed".into()).or_insert(0);
    stats.entry("team_mention".into()).or_insert(0);
    Ok(stats)
}

async fn get_on_page(token: &str, page: usize) -> Result<Vec<Notification>> {
    #[derive(Deserialize)]
    #[serde(untagged)]
    enum Response {
        Notifications(Vec<Notification>),
        ErrorMessage { message: String },
    }

    // https://docs.github.com/en/rest/reference/activity#notifications
    let request = REQWEST_CLIENT
        .get(format!(
            "https://api.github.com/notifications?per_page=100&page={page}",
        ))
        .header("Authorization", format!("token {token}"));
    let response = request
        .send()
        .await
        .error("Failed to send request")?
        .json::<Response>()
        .await
        .error("Failed to get JSON")?;

    match response {
        Response::Notifications(n) => Ok(n),
        Response::ErrorMessage { message } => Err(Error::new(format!("API error: {message}"))),
    }
}