use async_trait::async_trait;
use serde::Deserialize;
use crate::options::OptionSchema;
use crate::payload::{
BadgeData, Body, EntriesData, Entry, LinkedLine, LinkedTextBlockData, Payload, Status,
TextBlockData, TimelineData, TimelineEvent,
};
use crate::render::Shape;
use crate::samples;
use super::super::{FetchContext, FetchError, Fetcher, Safety};
use super::client::rest_get;
use super::common::{cache_key, parse_options, parse_timestamp, payload};
const SHAPES: &[Shape] = &[
Shape::LinkedTextBlock,
Shape::TextBlock,
Shape::Entries,
Shape::Timeline,
Shape::Badge,
];
const DEFAULT_LIMIT: u32 = 10;
const BADGE_PAGE: u32 = 50;
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "limit",
type_hint: "integer (1..=50)",
required: false,
default: Some("10"),
description: "Maximum number of notifications to show.",
},
OptionSchema {
name: "all",
type_hint: "boolean",
required: false,
default: Some("false"),
description: "Include already-read notifications. Defaults to unread-only.",
},
];
pub struct GithubNotifications;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Options {
#[serde(default)]
pub limit: Option<u32>,
#[serde(default)]
pub all: Option<bool>,
}
#[async_trait]
impl Fetcher for GithubNotifications {
fn name(&self) -> &str {
"github_notifications"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"The authenticated user's GitHub notification inbox, unread by default, with each row tagged by reason (mention, review_requested, etc.). Set `all = true` to include already-read notifications."
}
fn shapes(&self) -> &[Shape] {
SHAPES
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
fn cache_key(&self, ctx: &FetchContext) -> String {
cache_key(self.name(), ctx, "")
}
fn sample_body(&self, shape: Shape) -> Option<Body> {
Some(match shape {
Shape::LinkedTextBlock => samples::linked_text_block(&[
(
"splashboard review_requested: feat: heatmap",
Some("https://github.com/unhappychoice/splashboard/pull/12"),
),
(
"ratatui mention: rfc: themes",
Some("https://github.com/ratatui/ratatui/issues/345"),
),
]),
Shape::TextBlock => samples::text_block(&[
"splashboard review_requested: feat: heatmap",
"ratatui mention: rfc: themes",
]),
Shape::Entries => samples::entries(&[
("splashboard", "review_requested: feat: heatmap"),
("ratatui", "mention: rfc: themes"),
]),
Shape::Timeline => samples::timeline(&[
(
1_774_000_000,
"splashboard",
Some("review_requested: feat: heatmap"),
),
(1_773_800_000, "ratatui", Some("mention: rfc: themes")),
]),
Shape::Badge => samples::badge(Status::Warn, "2 notifications"),
_ => return None,
})
}
async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
let opts: Options = parse_options(ctx.options.as_ref()).map_err(FetchError::Failed)?;
let shape = ctx.shape.unwrap_or(Shape::LinkedTextBlock);
let per_page = if shape == Shape::Badge {
BADGE_PAGE
} else {
opts.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, BADGE_PAGE)
};
let all = opts.all.unwrap_or(false);
let path = format!("/notifications?per_page={per_page}&all={all}");
let items: Vec<Notification> = rest_get(&path).await?;
let capped = items.len() as u32 >= per_page;
Ok(payload(render_body(&items, shape, capped)))
}
}
#[derive(Debug, Deserialize)]
struct Notification {
reason: String,
updated_at: String,
subject: Subject,
repository: Repo,
}
#[derive(Debug, Deserialize)]
struct Subject {
title: String,
#[serde(default)]
url: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Repo {
full_name: String,
}
fn render_body(items: &[Notification], shape: Shape, capped: bool) -> Body {
match shape {
Shape::Entries => Body::Entries(EntriesData {
items: items
.iter()
.map(|n| Entry {
key: short_repo(&n.repository.full_name),
value: Some(format!("{}: {}", n.reason, n.subject.title)),
status: None,
})
.collect(),
}),
Shape::LinkedTextBlock => Body::LinkedTextBlock(LinkedTextBlockData {
items: items
.iter()
.map(|n| LinkedLine {
text: format!(
"{} {}: {}",
n.repository.full_name, n.reason, n.subject.title
),
url: subject_html_url(n),
})
.collect(),
}),
Shape::Timeline => Body::Timeline(TimelineData {
events: items
.iter()
.map(|n| TimelineEvent {
timestamp: parse_timestamp(&n.updated_at),
title: short_repo(&n.repository.full_name),
detail: Some(format!("{}: {}", n.reason, n.subject.title)),
status: None,
})
.collect(),
}),
Shape::Badge => Body::Badge(notifications_badge(items.len(), capped)),
_ => Body::TextBlock(TextBlockData {
lines: items
.iter()
.map(|n| {
format!(
"{} {}: {}",
n.repository.full_name, n.reason, n.subject.title
)
})
.collect(),
}),
}
}
fn notifications_badge(count: usize, capped: bool) -> BadgeData {
BadgeData {
status: if count == 0 { Status::Ok } else { Status::Warn },
label: match (count, capped) {
(0, _) => "inbox zero".into(),
(1, false) => "1 notification".into(),
(n, true) => format!("{n}+ notifications"),
(n, false) => format!("{n} notifications"),
},
}
}
fn short_repo(full: &str) -> String {
full.split_once('/')
.map(|(_, r)| r.to_string())
.unwrap_or_else(|| full.to_string())
}
fn subject_html_url(n: &Notification) -> Option<String> {
let url = n.subject.url.as_deref()?;
let rest = url.strip_prefix("https://api.github.com/repos/")?;
Some(format!("https://github.com/{rest}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Notification {
Notification {
reason: "review_requested".into(),
updated_at: "2026-04-22T10:15:30Z".into(),
subject: Subject {
title: "feat: heatmap".into(),
url: Some("https://api.github.com/repos/unhappychoice/splashboard/pulls/1".into()),
},
repository: Repo {
full_name: "unhappychoice/splashboard".into(),
},
}
}
#[test]
fn short_repo_drops_owner() {
assert_eq!(short_repo("unhappychoice/splashboard"), "splashboard");
assert_eq!(short_repo("singleton"), "singleton");
}
#[test]
fn badge_body_collapses_to_count_pill() {
let body = render_body(&[sample(), sample()], Shape::Badge, false);
let Body::Badge(b) = body else {
panic!("expected badge")
};
assert_eq!(b.status, Status::Warn);
assert_eq!(b.label, "2 notifications");
let body = render_body(&[], Shape::Badge, false);
let Body::Badge(b) = body else {
panic!("expected badge")
};
assert_eq!(b.status, Status::Ok);
assert_eq!(b.label, "inbox zero");
}
#[test]
fn badge_label_marks_capped_count_with_plus() {
let items: Vec<Notification> = (0..BADGE_PAGE).map(|_| sample()).collect();
let body = render_body(&items, Shape::Badge, true);
let Body::Badge(b) = body else {
panic!("expected badge")
};
assert_eq!(b.label, format!("{BADGE_PAGE}+ notifications"));
}
#[test]
fn text_block_body_composes_repo_reason_title() {
let body = render_body(&[sample()], Shape::TextBlock, false);
let Body::TextBlock(l) = body else {
panic!("expected text_block");
};
assert!(l.lines[0].contains("unhappychoice/splashboard"));
assert!(l.lines[0].contains("review_requested"));
assert!(l.lines[0].contains("feat: heatmap"));
}
}