i3status_rs/blocks/
github.rs1use 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
77const 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 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 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}