use std::sync::Arc;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::json;
use crate::fetcher::{FetchContext, FetchError, Fetcher, Safety};
use crate::options::OptionSchema;
use crate::payload::{
BadgeData, Bar, BarsData, Body, EntriesData, Entry, LinkedLine, LinkedTextBlockData,
MarkdownTextBlockData, Payload, Status, TextBlockData, TextData, TimelineData, TimelineEvent,
};
use crate::render::Shape;
use crate::samples;
use super::client::{graphql_query, resolve_token};
const SHAPES: &[Shape] = &[
Shape::LinkedTextBlock,
Shape::Text,
Shape::TextBlock,
Shape::MarkdownTextBlock,
Shape::Entries,
Shape::Bars,
Shape::Badge,
Shape::Timeline,
];
const DEFAULT_LIMIT: usize = 10;
const MAX_LIMIT: usize = 100;
const FETCH_LIMIT: i64 = 50;
const QUERY: &str = r#"
query Notifications($first: Int) {
notifications(first: $first) {
nodes {
id
type
readAt
createdAt
... on IssueNotification {
actor { displayName email }
issue { identifier title url team { key } }
comment { body }
}
... on ProjectNotification {
actor { displayName email }
project { name url }
}
... on PullRequestNotification {
actor { displayName email }
pullRequest { number title url }
}
}
}
}
"#;
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "token",
type_hint: "string",
required: false,
default: None,
description: "Linear personal API key (`lin_api_*`). Falls back to `LINEAR_TOKEN` env.",
},
OptionSchema {
name: "filter_read",
type_hint: "\"unread\" | \"read\" | \"all\"",
required: false,
default: Some("\"unread\""),
description: "Read-state filter.",
},
OptionSchema {
name: "filter_type",
type_hint: "\"mention\" | \"assigned\" | \"comment\" | \"status_changed\" | \"project_update\" | \"any\"",
required: false,
default: Some("\"any\""),
description: "Notification type filter (matched on the API `type` string).",
},
OptionSchema {
name: "filter_team",
type_hint: "string",
required: false,
default: None,
description: "Team key (e.g. `\"ENG\"`) — keeps only issue notifications from the team.",
},
OptionSchema {
name: "limit",
type_hint: "integer (1..=100)",
required: false,
default: Some("10"),
description: "Max rows for list-shaped renderers.",
},
];
pub struct LinearNotifications;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
#[serde(default)]
token: Option<String>,
#[serde(default)]
filter_read: Option<String>,
#[serde(default)]
filter_type: Option<String>,
#[serde(default)]
filter_team: Option<String>,
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
struct ApiResponse {
notifications: ApiConnection<ApiNotification>,
}
#[derive(Debug, Deserialize)]
struct ApiConnection<T> {
nodes: Vec<T>,
}
#[derive(Debug, Deserialize)]
struct ApiNotification {
#[serde(rename = "type")]
notif_type: String,
#[serde(rename = "readAt", default)]
read_at: Option<String>,
#[serde(rename = "createdAt")]
created_at: String,
#[serde(default)]
actor: Option<ApiUser>,
#[serde(default)]
issue: Option<ApiIssue>,
#[serde(default)]
project: Option<ApiProject>,
#[serde(default)]
comment: Option<ApiComment>,
#[serde(rename = "pullRequest", default)]
pull_request: Option<ApiPullRequest>,
}
#[derive(Debug, Deserialize)]
struct ApiPullRequest {
number: i64,
title: String,
url: String,
}
#[derive(Debug, Deserialize)]
struct ApiProject {
name: String,
url: String,
}
#[derive(Debug, Deserialize)]
struct ApiComment {
#[serde(default)]
body: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ApiUser {
#[serde(rename = "displayName", default)]
display_name: Option<String>,
#[serde(default)]
email: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ApiIssue {
identifier: String,
title: String,
url: String,
#[serde(default)]
team: Option<ApiTeam>,
}
#[derive(Debug, Deserialize)]
struct ApiTeam {
key: String,
}
#[derive(Debug)]
struct NotifView {
notif_type: String,
type_label: String,
actor: Option<String>,
issue_identifier: Option<String>,
issue_title: Option<String>,
issue_url: Option<String>,
team_key: Option<String>,
snippet: Option<String>,
created_ts: i64,
is_unread: bool,
}
#[async_trait]
impl Fetcher for LinearNotifications {
fn name(&self) -> &str {
"linear_notifications"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"Linear inbox snapshot — mentions, assignments, comments, status changes — with structured filters for read state, type, and team. List shapes link each row to the originating issue."
}
fn shapes(&self) -> &[Shape] {
SHAPES
}
fn default_shape(&self) -> Shape {
Shape::LinkedTextBlock
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
fn cache_key(&self, ctx: &FetchContext) -> String {
super::cache_key("linear_notifications", ctx)
}
fn sample_body(&self, shape: Shape) -> Option<Body> {
Some(match shape {
Shape::LinkedTextBlock => samples::linked_text_block(&[
(
"ENG-123 @sarah mentioned you · 2h ago",
Some("https://linear.app/acme/issue/ENG-123"),
),
(
"ENG-118 assigned by @max · yesterday",
Some("https://linear.app/acme/issue/ENG-118"),
),
]),
Shape::Text => samples::text("3 unread"),
Shape::TextBlock => samples::text_block(&[
"ENG-123 @sarah mentioned you · 2h ago",
"ENG-118 assigned by @max · yesterday",
]),
Shape::MarkdownTextBlock => samples::markdown(
"- **ENG-123** *mention* by @sarah · 2h ago\n- **ENG-118** *assigned* by @max · yesterday",
),
Shape::Entries => samples::entries(&[
("ENG-123", "@sarah mentioned you"),
("ENG-118", "assigned by @max"),
]),
Shape::Bars => samples::bars(&[("Mention", 2), ("Assigned", 1)]),
Shape::Badge => samples::badge(Status::Warn, "linear inbox 3"),
Shape::Timeline => samples::timeline(&[
(
1_745_896_800,
"ENG-123 mention by @sarah",
Some("Fix login flow"),
),
(
1_745_810_400,
"ENG-118 assigned by @max",
Some("Polish dashboard"),
),
]),
_ => return None,
})
}
async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
let opts: Options = parse_options(ctx.options.as_ref())?;
let token = resolve_token(opts.token.as_deref())?;
let response: ApiResponse =
graphql_query(&token, QUERY, json!({ "first": FETCH_LIMIT })).await?;
let mut views: Vec<NotifView> = response
.notifications
.nodes
.into_iter()
.map(to_view)
.filter(|v| matches_read(v, opts.filter_read.as_deref()))
.filter(|v| matches_type(v, opts.filter_type.as_deref()))
.filter(|v| matches_team(v, opts.filter_team.as_deref()))
.collect();
views.sort_by_key(|v| std::cmp::Reverse(v.created_ts));
let limit = opts.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
let shape = ctx.shape.unwrap_or(Shape::LinkedTextBlock);
Ok(payload(render_body(&views, shape, limit)))
}
}
pub fn fetcher() -> Arc<dyn Fetcher> {
Arc::new(LinearNotifications)
}
fn parse_options(raw: Option<&toml::Value>) -> Result<Options, FetchError> {
raw.map(|v| {
v.clone()
.try_into::<Options>()
.map_err(|e| FetchError::Failed(format!("invalid options: {e}")))
})
.unwrap_or_else(|| Ok(Options::default()))
}
fn matches_read(v: &NotifView, raw: Option<&str>) -> bool {
match raw.unwrap_or("unread").trim().to_lowercase().as_str() {
"read" => !v.is_unread,
"all" => true,
_ => v.is_unread,
}
}
fn matches_type(v: &NotifView, raw: Option<&str>) -> bool {
let kind = raw.unwrap_or("any").trim().to_lowercase();
if kind == "any" {
return true;
}
let t = v.notif_type.to_lowercase();
match kind.as_str() {
"mention" => t.contains("mention"),
"assigned" => t.contains("assigned"),
"comment" => t.contains("comment"),
"status_changed" => t.contains("status"),
"project_update" => t.starts_with("projectupdate"),
_ => true,
}
}
fn matches_team(v: &NotifView, raw: Option<&str>) -> bool {
let Some(team) = raw.map(str::trim).filter(|s| !s.is_empty()) else {
return true;
};
v.team_key.as_deref().is_some_and(|k| k == team)
}
fn to_view(api: ApiNotification) -> NotifView {
let type_label = pretty_type(&api.notif_type);
let actor = api.actor.and_then(|a| a.display_name.or(a.email));
let (subject_id, subject_title, subject_url, team_key) = if let Some(i) = api.issue {
(
Some(i.identifier),
Some(i.title),
Some(i.url),
i.team.map(|t| t.key),
)
} else if let Some(pr) = api.pull_request {
let repo = repo_from_pr_url(&pr.url);
let id = match repo {
Some(r) => format!("{r}#{}", pr.number),
None => format!("PR #{}", pr.number),
};
(Some(id), Some(pr.title), Some(pr.url), None)
} else if let Some(p) = api.project {
(None, Some(p.name), Some(p.url), None)
} else {
(None, None, None, None)
};
let snippet = api.comment.and_then(|c| c.body).and_then(short_snippet);
NotifView {
notif_type: api.notif_type,
type_label,
actor,
issue_identifier: subject_id,
issue_title: subject_title,
issue_url: subject_url,
team_key,
snippet,
created_ts: parse_ts(&api.created_at),
is_unread: api.read_at.is_none(),
}
}
fn repo_from_pr_url(url: &str) -> Option<String> {
let path = url.split("github.com/").nth(1)?;
let mut parts = path.split('/');
let owner = parts.next()?;
let repo = parts.next()?;
if owner.is_empty() || repo.is_empty() {
None
} else {
Some(format!("{owner}/{repo}"))
}
}
fn short_snippet(body: String) -> Option<String> {
let line = body
.lines()
.map(str::trim)
.find(|l| !l.is_empty())?
.to_string();
if line.chars().count() > 80 {
Some(format!("{}…", line.chars().take(79).collect::<String>()))
} else {
Some(line)
}
}
fn pretty_type(raw: &str) -> String {
let lower = raw.to_lowercase();
if lower.contains("pullrequestreview") || lower.contains("reviewrequested") {
"PR review".into()
} else if lower.contains("pullrequest") {
"PR".into()
} else if lower.contains("mention") {
"mention".into()
} else if lower.contains("assigned") {
"assigned".into()
} else if lower.contains("comment") {
"comment".into()
} else if lower.contains("status") {
"status".into()
} else if lower.starts_with("projectupdate") {
"project update".into()
} else if lower.contains("subscribed") {
"subscribed".into()
} else if lower.contains("created") {
"created".into()
} else if lower.contains("due") || lower.contains("reminder") {
"reminder".into()
} else {
humanize_camel(raw)
}
}
fn humanize_camel(raw: &str) -> String {
let mut out = String::with_capacity(raw.len() + 4);
for (i, ch) in raw.chars().enumerate() {
if ch.is_ascii_uppercase() && i > 0 {
out.push(' ');
}
out.push(ch.to_ascii_lowercase());
}
out
}
fn parse_ts(raw: &str) -> i64 {
chrono::DateTime::parse_from_rfc3339(raw)
.map(|dt| dt.timestamp())
.unwrap_or(0)
}
fn render_body(views: &[NotifView], shape: Shape, limit: usize) -> Body {
match shape {
Shape::Text => Body::Text(TextData {
value: format!("{} unread", views.iter().filter(|v| v.is_unread).count()),
}),
Shape::TextBlock => Body::TextBlock(TextBlockData {
lines: views.iter().take(limit).map(line_for).collect(),
}),
Shape::MarkdownTextBlock => Body::MarkdownTextBlock(MarkdownTextBlockData {
value: views
.iter()
.take(limit)
.map(markdown_line_for)
.collect::<Vec<_>>()
.join("\n"),
}),
Shape::LinkedTextBlock => Body::LinkedTextBlock(LinkedTextBlockData {
items: views
.iter()
.take(limit)
.map(|v| LinkedLine {
text: line_for(v),
url: v.issue_url.clone(),
})
.collect(),
}),
Shape::Entries => Body::Entries(EntriesData {
items: views
.iter()
.take(limit)
.map(|v| Entry {
key: v
.issue_identifier
.clone()
.unwrap_or_else(|| v.type_label.clone()),
value: Some(entry_value(v)),
status: v.is_unread.then_some(Status::Warn),
})
.collect(),
}),
Shape::Bars => Body::Bars(BarsData {
bars: tally_by_type(views),
}),
Shape::Badge => Body::Badge(BadgeData {
status: badge_status(views),
label: format!(
"linear inbox {}",
views.iter().filter(|v| v.is_unread).count()
),
}),
Shape::Timeline => Body::Timeline(TimelineData {
events: views
.iter()
.take(limit)
.map(|v| TimelineEvent {
timestamp: v.created_ts,
title: timeline_title(v),
detail: v.issue_title.clone(),
status: v.is_unread.then_some(Status::Warn),
})
.collect(),
}),
_ => Body::TextBlock(TextBlockData {
lines: views.iter().take(limit).map(line_for).collect(),
}),
}
}
fn line_for(v: &NotifView) -> String {
join_parts(&[
v.issue_identifier.clone(),
Some(v.type_label.clone()),
v.actor.as_deref().map(|a| format!("by @{a}")),
v.issue_title.clone().or_else(|| v.snippet.clone()),
])
}
fn markdown_line_for(v: &NotifView) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(id) = &v.issue_identifier {
parts.push(format!("**{id}**"));
}
parts.push(format!("*{}*", v.type_label));
if let Some(a) = &v.actor {
parts.push(format!("by @{a}"));
}
if let Some(title) = v.issue_title.as_ref().or(v.snippet.as_ref()) {
parts.push(title.clone());
}
format!("- {}", parts.join(" · "))
}
fn entry_value(v: &NotifView) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(a) = &v.actor {
parts.push(format!("@{a}"));
}
parts.push(v.type_label.clone());
if let Some(title) = v.issue_title.as_ref().or(v.snippet.as_ref()) {
parts.push(title.clone());
}
parts.join(" · ")
}
fn timeline_title(v: &NotifView) -> String {
line_for(v)
}
fn join_parts(parts: &[Option<String>]) -> String {
parts
.iter()
.filter_map(|p| p.as_deref().filter(|s| !s.is_empty()))
.collect::<Vec<_>>()
.join(" · ")
}
fn tally_by_type(views: &[NotifView]) -> Vec<Bar> {
let mut counts: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
for v in views {
let label = capitalize(&v.type_label);
*counts.entry(label).or_insert(0) += 1;
}
let mut bars: Vec<Bar> = counts
.into_iter()
.map(|(label, value)| Bar { label, value })
.collect();
bars.sort_by(|a, b| b.value.cmp(&a.value).then_with(|| a.label.cmp(&b.label)));
bars
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
chars
.next()
.map(|c| c.to_uppercase().chain(chars).collect::<String>())
.unwrap_or_default()
}
fn badge_status(views: &[NotifView]) -> Status {
let unread = views.iter().filter(|v| v.is_unread).count();
match unread {
0 => Status::Ok,
1..=3 => Status::Ok,
4..=9 => Status::Warn,
_ => Status::Error,
}
}
fn payload(body: Body) -> Payload {
Payload {
icon: None,
status: None,
format: None,
body,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_read_unread_default_keeps_only_unread_rows() {
let unread = view_for_test("issueMention", true, Some("E-1"));
let read = view_for_test("issueMention", false, Some("E-2"));
assert!(matches_read(&unread, None));
assert!(!matches_read(&read, None));
}
#[test]
fn matches_read_read_inverts_the_filter() {
let unread = view_for_test("issueMention", true, Some("E-1"));
let read = view_for_test("issueMention", false, Some("E-2"));
assert!(!matches_read(&unread, Some("read")));
assert!(matches_read(&read, Some("read")));
}
#[test]
fn matches_read_all_keeps_everything() {
let unread = view_for_test("issueMention", true, Some("E-1"));
let read = view_for_test("issueMention", false, Some("E-2"));
assert!(matches_read(&unread, Some("all")));
assert!(matches_read(&read, Some("all")));
}
#[test]
fn pretty_type_collapses_known_synonyms() {
assert_eq!(pretty_type("issueMention"), "mention");
assert_eq!(pretty_type("issueAssignedToYou"), "assigned");
assert_eq!(pretty_type("issueCommentMention"), "mention");
assert_eq!(pretty_type("issueStatusChanged"), "status");
assert_eq!(pretty_type("projectUpdateCreated"), "project update");
}
#[test]
fn matches_type_filters_by_substring() {
let v = view_for_test("issueMention", true, Some("ENG-1"));
assert!(matches_type(&v, Some("mention")));
assert!(!matches_type(&v, Some("assigned")));
assert!(matches_type(&v, Some("any")));
}
#[test]
fn matches_team_keeps_only_specified_team() {
let v = view_for_test("issueMention", true, Some("ENG-1"));
assert!(matches_team(&v, None));
assert!(matches_team(&v, Some("ENG")));
assert!(!matches_team(&v, Some("OPS")));
}
#[test]
fn fetcher_contract_and_parse_options_cover_catalog_surface() {
let fetcher = LinearNotifications;
let default = fetcher.cache_key(&FetchContext::default());
let custom = fetcher.cache_key(&FetchContext {
shape: Some(Shape::Badge),
options: Some(toml::from_str("filter_type = \"comment\"\nlimit = 3").unwrap()),
..FetchContext::default()
});
let raw: toml::Value = toml::from_str(
"token = \"lin_api_test\"\nfilter_read = \"all\"\nfilter_type = \"comment\"\nfilter_team = \"ENG\"\nlimit = 3",
)
.unwrap();
assert_eq!(fetcher.default_shape(), Shape::LinkedTextBlock);
assert_ne!(default, custom);
assert!(fetcher.sample_body(Shape::Image).is_none());
assert!(parse_options(None).unwrap().limit.is_none());
assert_eq!(parse_options(Some(&raw)).unwrap().limit, Some(3));
assert!(matches!(
parse_options(Some(&toml::from_str("bogus = true").unwrap())),
Err(FetchError::Failed(message)) if message.contains("invalid options")
));
}
#[test]
fn matches_type_and_pretty_type_cover_remaining_labels() {
let comment = view_for_test("issueCommentCreated", true, Some("ENG-1"));
let status = view_for_test("issueStatusChanged", true, Some("ENG-2"));
let project = view_for_test("projectUpdateCreated", true, None);
assert!(matches_type(&comment, Some("comment")));
assert!(matches_type(&status, Some("status_changed")));
assert!(matches_type(&project, Some("project_update")));
assert!(matches_type(&comment, Some("mystery")));
assert_eq!(pretty_type("issueSubscribed"), "subscribed");
assert_eq!(pretty_type("issueCreated"), "created");
assert_eq!(pretty_type("issueDueSoon"), "reminder");
}
#[test]
fn helpers_treat_blank_filters_and_empty_values_as_absent() {
let v = view_for_test("issueMention", true, Some("ENG-1"));
assert!(matches_team(&v, Some(" ")));
assert_eq!(capitalize(""), "");
assert_eq!(
join_parts(&[Some("ENG-1".into()), Some(String::new()), None]),
"ENG-1"
);
assert!(short_snippet(" \n\t\n ".into()).is_none());
}
#[test]
fn render_text_emits_unread_count() {
let views = vec![
view_for_test("issueMention", true, Some("E-1")),
view_for_test("issueMention", false, Some("E-2")),
];
let body = render_body(&views, Shape::Text, 10);
let Body::Text(t) = body else {
panic!("expected text");
};
assert!(t.value.contains("1"));
}
#[test]
fn render_linked_text_block_carries_issue_url() {
let views = vec![view_for_test("issueMention", true, Some("ENG-7"))];
let body = render_body(&views, Shape::LinkedTextBlock, 10);
let Body::LinkedTextBlock(b) = body else {
panic!("expected linked");
};
assert!(b.items[0].url.as_deref().unwrap().contains("ENG-7"));
}
#[test]
fn render_bars_groups_by_pretty_type() {
let views = vec![
view_for_test("issueMention", true, Some("E-1")),
view_for_test("issueMention", true, Some("E-2")),
view_for_test("issueAssignedToYou", true, Some("E-3")),
];
let body = render_body(&views, Shape::Bars, 10);
let Body::Bars(b) = body else {
panic!("expected bars");
};
assert_eq!(b.bars[0].label, "Mention");
assert_eq!(b.bars[0].value, 2);
}
#[test]
fn badge_critical_when_many_unread() {
let views: Vec<NotifView> = (0..15)
.map(|i| view_for_test("issueMention", true, Some(&format!("E-{i}"))))
.collect();
let body = render_body(&views, Shape::Badge, 10);
let Body::Badge(b) = body else {
panic!("expected badge");
};
assert_eq!(b.status, Status::Error);
}
fn view_for_test(notif_type: &str, unread: bool, identifier: Option<&str>) -> NotifView {
NotifView {
notif_type: notif_type.into(),
type_label: pretty_type(notif_type),
actor: Some("sarah".into()),
issue_identifier: identifier.map(String::from),
issue_title: identifier.map(|s| format!("Issue {s}")),
issue_url: identifier.map(|s| format!("https://linear.app/acme/issue/{s}")),
team_key: identifier.map(|_| "ENG".into()),
snippet: None,
created_ts: 1_745_896_800,
is_unread: unread,
}
}
#[test]
fn short_snippet_takes_first_non_empty_line_and_caps_length() {
let s = short_snippet("\n\n Hello world\nsecond line".to_string()).unwrap();
assert_eq!(s, "Hello world");
let long = "x".repeat(120);
let trimmed = short_snippet(long).unwrap();
assert!(trimmed.chars().count() <= 80);
assert!(trimmed.ends_with('…'));
}
#[test]
fn repo_from_pr_url_extracts_owner_slash_repo() {
assert_eq!(
repo_from_pr_url("https://github.com/acme/widgets/pull/123"),
Some("acme/widgets".into())
);
}
#[test]
fn repo_from_pr_url_returns_none_for_non_github_host() {
assert_eq!(
repo_from_pr_url("https://gitlab.com/acme/widgets/-/merge_requests/9"),
None
);
}
#[test]
fn line_for_falls_back_to_snippet_when_no_issue_title() {
let mut v = view_for_test("pullRequestReviewRequested", true, None);
v.snippet = Some("Please review the auth refactor".into());
let line = line_for(&v);
assert!(line.contains("Please review"), "got: {line}");
}
#[test]
fn pretty_type_maps_pr_review_distinct_from_pr() {
assert_eq!(pretty_type("pullRequestReviewRequested"), "PR review");
assert_eq!(pretty_type("pullRequestComment"), "PR");
assert_eq!(pretty_type("pullRequestMerged"), "PR");
}
#[test]
fn pretty_type_humanises_unknown_camel_case() {
assert_eq!(pretty_type("issueDoneSomething"), "issue done something");
}
#[test]
fn line_for_omits_actor_segment_when_actor_missing() {
let mut v = view_for_test("issueMention", true, Some("ENG-1"));
v.actor = None;
let line = line_for(&v);
assert!(
!line.contains("@"),
"actor segment should be elided: {line}"
);
assert!(line.contains("ENG-1"));
}
#[test]
fn render_text_block_caps_at_limit() {
let views: Vec<NotifView> = (0..15)
.map(|i| view_for_test("issueMention", true, Some(&format!("E-{i}"))))
.collect();
let body = render_body(&views, Shape::TextBlock, 5);
let Body::TextBlock(b) = body else {
panic!("expected text_block");
};
assert_eq!(b.lines.len(), 5);
}
#[test]
fn render_entries_marks_unread_with_warn_status() {
let unread = view_for_test("issueMention", true, Some("ENG-1"));
let read = view_for_test("issueMention", false, Some("ENG-2"));
let body = render_body(&[unread, read], Shape::Entries, 10);
let Body::Entries(e) = body else {
panic!("expected entries");
};
assert_eq!(e.items[0].status, Some(Status::Warn));
assert_eq!(e.items[1].status, None);
}
#[test]
fn render_timeline_event_carries_created_ts_and_unread_status() {
let v = view_for_test("issueMention", true, Some("ENG-1"));
let body = render_body(&[v], Shape::Timeline, 10);
let Body::Timeline(t) = body else {
panic!("expected timeline");
};
assert_eq!(t.events[0].timestamp, 1_745_896_800);
assert_eq!(t.events[0].status, Some(Status::Warn));
}
#[test]
fn badge_warn_when_unread_in_warn_band() {
let views: Vec<NotifView> = (0..5)
.map(|i| view_for_test("issueMention", true, Some(&format!("E-{i}"))))
.collect();
let body = render_body(&views, Shape::Badge, 10);
let Body::Badge(b) = body else {
panic!("expected badge");
};
assert_eq!(b.status, Status::Warn);
}
#[test]
fn render_markdown_entries_and_fallback_shapes_use_snippet_and_type_label() {
let mut v = view_for_test("issueCommentCreated", true, None);
v.actor = None;
v.issue_title = None;
v.snippet = Some("Need eyes on the migration".into());
let markdown = render_body(&[v], Shape::MarkdownTextBlock, 10);
let Body::MarkdownTextBlock(markdown) = markdown else {
panic!("expected markdown_text_block");
};
assert_eq!(markdown.value, "- *comment* · Need eyes on the migration");
let entries = render_body(
&[view_for_test("issueCommentCreated", true, None)],
Shape::Entries,
10,
);
let Body::Entries(entries) = entries else {
panic!("expected entries");
};
assert_eq!(entries.items[0].key, "comment");
let mut fallback = view_for_test("issueCommentCreated", true, None);
fallback.actor = None;
fallback.issue_title = None;
fallback.snippet = Some("Need eyes on the migration".into());
let text = render_body(&[fallback], Shape::Image, 10);
let Body::TextBlock(text) = text else {
panic!("expected text_block");
};
assert_eq!(text.lines[0], "comment · Need eyes on the migration");
}
#[test]
fn tally_sort_badge_zero_and_payload_wrapper_cover_helper_paths() {
let bars = tally_by_type(&[
view_for_test("issueStatusChanged", true, Some("ENG-1")),
view_for_test("issueAssignedToYou", true, Some("ENG-2")),
]);
let payload = payload(Body::Text(TextData { value: "ok".into() }));
assert_eq!(bars[0].label, "Assigned");
assert_eq!(bars[1].label, "Status");
assert_eq!(badge_status(&[]), Status::Ok);
assert!(payload.icon.is_none());
assert!(payload.status.is_none());
}
#[test]
fn to_view_prefers_pull_request_over_project_when_both_absent_for_issue() {
let api = ApiNotification {
notif_type: "pullRequestReviewRequested".into(),
read_at: None,
created_at: "2026-04-29T00:00:00Z".into(),
actor: None,
issue: None,
project: None,
comment: None,
pull_request: Some(ApiPullRequest {
number: 42,
title: "Fix login flow".into(),
url: "https://github.com/acme/widgets/pull/42".into(),
}),
};
let v = to_view(api);
assert_eq!(v.issue_identifier.as_deref(), Some("acme/widgets#42"));
assert_eq!(v.issue_title.as_deref(), Some("Fix login flow"));
assert!(v.is_unread);
}
#[test]
fn to_view_uses_issue_branch_email_fallback_and_comment_snippet() {
let api = ApiNotification {
notif_type: "issueCommentCreated".into(),
read_at: Some("2026-04-29T00:00:00Z".into()),
created_at: "2026-04-28T12:34:56Z".into(),
actor: Some(ApiUser {
display_name: None,
email: Some("sarah@example.com".into()),
}),
issue: Some(ApiIssue {
identifier: "ENG-42".into(),
title: "Ship notifications".into(),
url: "https://linear.app/acme/issue/ENG-42".into(),
team: Some(ApiTeam { key: "ENG".into() }),
}),
project: None,
comment: Some(ApiComment {
body: Some("\n\nNeed eyes on this\nsecond line".into()),
}),
pull_request: None,
};
let v = to_view(api);
assert_eq!(v.actor.as_deref(), Some("sarah@example.com"));
assert_eq!(v.issue_identifier.as_deref(), Some("ENG-42"));
assert_eq!(v.team_key.as_deref(), Some("ENG"));
assert_eq!(v.snippet.as_deref(), Some("Need eyes on this"));
assert!(!v.is_unread);
assert!(v.created_ts > 0);
}
#[test]
fn to_view_falls_back_when_pr_slug_or_subject_is_missing() {
let pr = ApiNotification {
notif_type: "pullRequestMerged".into(),
read_at: None,
created_at: "2026-04-29T00:00:00Z".into(),
actor: None,
issue: None,
project: None,
comment: None,
pull_request: Some(ApiPullRequest {
number: 9,
title: "Tighten filters".into(),
url: "https://github.com//widgets/pull/9".into(),
}),
};
let empty = ApiNotification {
notif_type: "issueCreated".into(),
read_at: None,
created_at: "not-a-date".into(),
actor: None,
issue: None,
project: None,
comment: Some(ApiComment {
body: Some("\n \n".into()),
}),
pull_request: None,
};
let pr_view = to_view(pr);
let empty_view = to_view(empty);
assert_eq!(pr_view.issue_identifier.as_deref(), Some("PR #9"));
assert_eq!(repo_from_pr_url("https://github.com//widgets/pull/9"), None);
assert_eq!(empty_view.issue_identifier, None);
assert_eq!(empty_view.issue_title, None);
assert_eq!(empty_view.snippet, None);
assert_eq!(empty_view.created_ts, 0);
}
#[test]
fn to_view_uses_project_when_no_issue_or_pull_request() {
let api = ApiNotification {
notif_type: "projectUpdateCreated".into(),
read_at: Some("2026-04-29T00:00:00Z".into()),
created_at: "2026-04-28T00:00:00Z".into(),
actor: None,
issue: None,
project: Some(ApiProject {
name: "Q2 Roadmap".into(),
url: "https://linear.app/acme/project/q2-roadmap".into(),
}),
comment: None,
pull_request: None,
};
let v = to_view(api);
assert_eq!(v.issue_identifier, None);
assert_eq!(v.issue_title.as_deref(), Some("Q2 Roadmap"));
assert!(!v.is_unread);
}
}