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}