From 0d41ce5bbd1f8170bca41f76a54dde7336ba5c6a Mon Sep 17 00:00:00 2001 From: Geoffrey Sechter Date: Sun, 1 Mar 2026 19:28:05 -0700 Subject: [PATCH] feat: notifications list, read, mark-read, archive, and unarchive commands --- crates/lineark-codegen/src/emit_types.rs | 16 +- crates/lineark-codegen/src/parser.rs | 7 + .../lineark-sdk/src/generated/client_impl.rs | 36 ++++ crates/lineark-sdk/src/generated/mutations.rs | 45 +++++ crates/lineark-sdk/src/generated/queries.rs | 120 ++++++++++++ crates/lineark-sdk/src/generated/types.rs | 173 +++++++++++++++++ crates/lineark-sdk/tests/online.rs | 48 +++++ crates/lineark/src/commands/mod.rs | 1 + crates/lineark/src/commands/notifications.rs | 174 ++++++++++++++++++ crates/lineark/src/commands/usage.rs | 5 + crates/lineark/src/main.rs | 3 + crates/lineark/tests/offline.rs | 65 +++++++ crates/lineark/tests/online.rs | 104 +++++++++++ schema/operations.toml | 9 + 14 files changed, 801 insertions(+), 5 deletions(-) create mode 100644 crates/lineark/src/commands/notifications.rs diff --git a/crates/lineark-codegen/src/emit_types.rs b/crates/lineark-codegen/src/emit_types.rs index 8c76dfc..cef98f8 100644 --- a/crates/lineark-codegen/src/emit_types.rs +++ b/crates/lineark-codegen/src/emit_types.rs @@ -68,13 +68,16 @@ fn emit_struct(obj: &ObjectDef, type_kind_map: &HashMap) -> To } /// Check if a field should be included in the struct. -/// Includes Scalar, Enum, and Object types (for nested objects). -/// Excludes Interface and Union types (not representable as simple structs). +/// Includes Scalar, Enum, Object, and Interface types. +/// Excludes Union types (not representable as simple structs). fn is_includable_field(ty: &GqlType, type_kind_map: &HashMap) -> bool { let base = ty.base_name(); matches!( type_kind_map.get(base), - Some(TypeKind::Scalar) | Some(TypeKind::Enum) | Some(TypeKind::Object) + Some(TypeKind::Scalar) + | Some(TypeKind::Enum) + | Some(TypeKind::Object) + | Some(TypeKind::Interface) ) } @@ -85,7 +88,10 @@ fn is_includable_field(ty: &GqlType, type_kind_map: &HashMap) fn resolve_type(ty: &GqlType, type_kind_map: &HashMap) -> TokenStream { let inner = resolve_inner_type(ty, type_kind_map); let base = ty.base_name(); - if matches!(type_kind_map.get(base), Some(TypeKind::Object)) { + if matches!( + type_kind_map.get(base), + Some(TypeKind::Object) | Some(TypeKind::Interface) + ) { quote! { Option> } } else { quote! { Option<#inner> } @@ -98,7 +104,7 @@ fn resolve_inner_type(ty: &GqlType, type_kind_map: &HashMap) - GqlType::Named(name) => { let base = name.as_str(); match type_kind_map.get(base) { - Some(TypeKind::Object) => { + Some(TypeKind::Object) | Some(TypeKind::Interface) => { let ident = quote::format_ident!("{}", name); quote! { #ident } } diff --git a/crates/lineark-codegen/src/parser.rs b/crates/lineark-codegen/src/parser.rs index b0c9318..bef7ceb 100644 --- a/crates/lineark-codegen/src/parser.rs +++ b/crates/lineark-codegen/src/parser.rs @@ -163,7 +163,14 @@ pub fn parse(schema_text: &str) -> ParsedSchema { } cst::Definition::InterfaceTypeDefinition(i) => { let name = extract_name(&i.name()); + let description = extract_description(&i.description()); type_kind_map.insert(name.clone(), TypeKind::Interface); + let fields = extract_fields(&i.fields_definition()); + objects.push(ObjectDef { + name, + description, + fields, + }); } cst::Definition::UnionTypeDefinition(u) => { let name = extract_name(&u.name()); diff --git a/crates/lineark-sdk/src/generated/client_impl.rs b/crates/lineark-sdk/src/generated/client_impl.rs index 1a13719..9552390 100644 --- a/crates/lineark-sdk/src/generated/client_impl.rs +++ b/crates/lineark-sdk/src/generated/client_impl.rs @@ -86,6 +86,23 @@ impl Client { ) -> Result { crate::generated::queries::project_milestone::(self, id).await } + /// All notifications. + /// + /// Full type: [`Notification`](super::types::Notification) + pub fn notifications(&self) -> NotificationsQueryBuilder<'_, T> { + crate::generated::queries::notifications(self) + } + /// One specific notification. + /// + /// Full type: [`Notification`](super::types::Notification) + pub async fn notification< + T: DeserializeOwned + GraphQLFields, + >( + &self, + id: String, + ) -> Result { + crate::generated::queries::notification::(self, id).await + } /// All issues. /// /// Full type: [`Issue`](super::types::Issue) @@ -319,6 +336,25 @@ impl Client { ) -> Result { crate::generated::mutations::project_milestone_delete(self, id).await } + /// Updates a notification. + pub async fn notification_update( + &self, + input: NotificationUpdateInput, + id: String, + ) -> Result { + crate::generated::mutations::notification_update(self, input, id).await + } + /// Archives a notification. + pub async fn notification_archive(&self, id: String) -> Result { + crate::generated::mutations::notification_archive(self, id).await + } + /// Unarchives a notification. + pub async fn notification_unarchive( + &self, + id: String, + ) -> Result { + crate::generated::mutations::notification_unarchive(self, id).await + } /// Creates a new issue. /// /// Full type: [`Issue`](super::types::Issue) diff --git a/crates/lineark-sdk/src/generated/mutations.rs b/crates/lineark-sdk/src/generated/mutations.rs index 97c4ec2..17808ce 100644 --- a/crates/lineark-sdk/src/generated/mutations.rs +++ b/crates/lineark-sdk/src/generated/mutations.rs @@ -288,6 +288,51 @@ pub async fn project_milestone_delete( .execute::(&query, variables, "projectMilestoneDelete") .await } +/// Updates a notification. +pub async fn notification_update( + client: &Client, + input: NotificationUpdateInput, + id: String, +) -> Result { + let variables = serde_json::json!({ "input" : input, "id" : id }); + let response_parts: Vec = vec!["success".to_string()]; + let query = String::from( + "mutation NotificationUpdate($input: NotificationUpdateInput!, $id: String!) { notificationUpdate(input: $input, id: $id) { ", + ) + &response_parts.join(" ") + " } }"; + client + .execute::(&query, variables, "notificationUpdate") + .await +} +/// Archives a notification. +pub async fn notification_archive( + client: &Client, + id: String, +) -> Result { + let variables = serde_json::json!({ "id" : id }); + let response_parts: Vec = vec!["success".to_string()]; + let query = String::from( + "mutation NotificationArchive($id: String!) { notificationArchive(id: $id) { ", + ) + &response_parts.join(" ") + + " } }"; + client + .execute::(&query, variables, "notificationArchive") + .await +} +/// Unarchives a notification. +pub async fn notification_unarchive( + client: &Client, + id: String, +) -> Result { + let variables = serde_json::json!({ "id" : id }); + let response_parts: Vec = vec!["success".to_string()]; + let query = String::from( + "mutation NotificationUnarchive($id: String!) { notificationUnarchive(id: $id) { ", + ) + &response_parts.join(" ") + + " } }"; + client + .execute::(&query, variables, "notificationUnarchive") + .await +} /// Creates a new issue. /// /// Full type: [`Issue`](super::types::Issue) diff --git a/crates/lineark-sdk/src/generated/queries.rs b/crates/lineark-sdk/src/generated/queries.rs index 542e21a..d5c971a 100644 --- a/crates/lineark-sdk/src/generated/queries.rs +++ b/crates/lineark-sdk/src/generated/queries.rs @@ -576,6 +576,93 @@ impl<'a, T: DeserializeOwned + GraphQLFields { + client: &'a Client, + filter: Option, + before: Option, + after: Option, + first: Option, + last: Option, + include_archived: Option, + order_by: Option, + _marker: std::marker::PhantomData, +} +impl<'a, T: DeserializeOwned + GraphQLFields> + NotificationsQueryBuilder<'a, T> +{ + pub fn filter(mut self, value: NotificationFilter) -> Self { + self.filter = Some(value); + self + } + pub fn before(mut self, value: impl Into) -> Self { + self.before = Some(value.into()); + self + } + pub fn after(mut self, value: impl Into) -> Self { + self.after = Some(value.into()); + self + } + pub fn first(mut self, value: i64) -> Self { + self.first = Some(value); + self + } + pub fn last(mut self, value: i64) -> Self { + self.last = Some(value); + self + } + pub fn include_archived(mut self, value: bool) -> Self { + self.include_archived = Some(value); + self + } + pub fn order_by(mut self, value: PaginationOrderBy) -> Self { + self.order_by = Some(value); + self + } + pub async fn send(self) -> Result, LinearError> { + let mut map = serde_json::Map::new(); + if let Some(ref v) = self.filter { + map.insert("filter".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.before { + map.insert("before".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.after { + map.insert("after".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.first { + map.insert("first".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.last { + map.insert("last".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.include_archived { + map.insert("includeArchived".to_string(), serde_json::json!(v)); + } + if let Some(ref v) = self.order_by { + map.insert("orderBy".to_string(), serde_json::json!(v)); + } + let variables = serde_json::Value::Object(map); + let selection = T::selection(); + let query = format!( + "query {}({}) {{ {}({}) {{ nodes {{ {} }} pageInfo {{ hasNextPage endCursor }} }} }}", + "Notifications", + "$filter: NotificationFilter, $before: String, $after: String, $first: Int, $last: Int, $includeArchived: Boolean, $orderBy: PaginationOrderBy", + "notifications", + "filter: $filter, before: $before, after: $after, first: $first, last: $last, includeArchived: $includeArchived, orderBy: $orderBy", + selection + ); + self.client + .execute_connection::(&query, variables, "notifications") + .await + } +} /// Query builder: All issues. /// /// Full type: [`Issue`](super::types::Issue) @@ -1176,6 +1263,39 @@ pub async fn project_milestone< .execute::(&query, variables, "projectMilestone") .await } +/// All notifications. +/// +/// Full type: [`Notification`](super::types::Notification) +pub fn notifications<'a, T>(client: &'a Client) -> NotificationsQueryBuilder<'a, T> { + NotificationsQueryBuilder { + client, + filter: None, + before: None, + after: None, + first: None, + last: None, + include_archived: None, + order_by: None, + _marker: std::marker::PhantomData, + } +} +/// One specific notification. +/// +/// Full type: [`Notification`](super::types::Notification) +pub async fn notification< + T: DeserializeOwned + GraphQLFields, +>( + client: &Client, + id: String, +) -> Result { + let variables = serde_json::json!({ "id" : id }); + let selection = T::selection(); + let query = format!( + "query {}({}) {{ {}({}) {{ {} }} }}", + "Notification", "$id: String!", "notification", "id: $id", selection + ); + client.execute::(&query, variables, "notification").await +} /// All issues. /// /// Full type: [`Issue`](super::types::Issue) diff --git a/crates/lineark-sdk/src/generated/types.rs b/crates/lineark-sdk/src/generated/types.rs index 72daf75..c52a3f8 100644 --- a/crates/lineark-sdk/src/generated/types.rs +++ b/crates/lineark-sdk/src/generated/types.rs @@ -4,6 +4,165 @@ use super::enums::*; use crate::field_selection::GraphQLFields; use serde::{Deserialize, Serialize}; +/// A generic payload return from entity archive or deletion mutations. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ArchivePayload { + /// The identifier of the last sync operation. + pub last_sync_id: Option, + /// Whether the operation was successful. + pub success: Option, +} +impl GraphQLFields for ArchivePayload { + type FullType = Self; + fn selection() -> String { + "lastSyncId success".into() + } +} +/// A basic entity. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Entity { + /// The unique identifier of the entity. + pub id: Option, + /// The time at which the entity was created. + pub created_at: Option>, + /// The last time at which the entity was meaningfully updated. This is the same as the creation time if the entity hasn't + /// been updated after creation. + pub updated_at: Option>, + /// The time at which the entity was archived. Null if the entity has not been archived. + pub archived_at: Option>, +} +impl GraphQLFields for Entity { + type FullType = Self; + fn selection() -> String { + "id createdAt updatedAt archivedAt".into() + } +} +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Node { + /// The unique identifier of the entity. + pub id: Option, +} +impl GraphQLFields for Node { + type FullType = Self; + fn selection() -> String { + "id".into() + } +} +/// A notification sent to a user. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Notification { + /// The unique identifier of the entity. + pub id: Option, + /// The time at which the entity was created. + pub created_at: Option>, + /// The last time at which the entity was meaningfully updated. This is the same as the creation time if the entity hasn't + /// been updated after creation. + pub updated_at: Option>, + /// The time at which the entity was archived. Null if the entity has not been archived. + pub archived_at: Option>, + /// Notification type. + pub r#type: Option, + /// The user that caused the notification. + pub actor: Option>, + /// The external user that caused the notification. + pub external_user_actor: Option>, + /// The user that received the notification. + pub user: Option>, + /// The time at when the user marked the notification as read. Null, if the the user hasn't read the notification + pub read_at: Option>, + /// The time at when an email reminder for this notification was sent to the user. Null, if no email + /// reminder has been sent. + pub emailed_at: Option>, + /// The time until a notification will be snoozed. After that it will appear in the inbox again. + pub snoozed_until_at: Option>, + /// The time at which a notification was unsnoozed.. + pub unsnoozed_at: Option>, + /// The category of the notification. + pub category: Option, + /// `Internal` URL to the target of the notification. + pub url: Option, + /// `Internal` Inbox URL for the notification. + pub inbox_url: Option, + /// `Internal` Notification title. + pub title: Option, + /// `Internal` Notification subtitle. + pub subtitle: Option, + /// `Internal` If notification actor was Linear. + pub is_linear_actor: Option, + /// `Internal` Notification avatar URL. + pub actor_avatar_url: Option, + /// `Internal` Notification actor initials if avatar is not available. + pub actor_initials: Option, + /// `Internal` Notification actor initials if avatar is not available. + pub actor_avatar_color: Option, + /// `Internal` Issue's status type for issue notifications. + pub issue_status_type: Option, + /// `Internal` Project update health for new updates. + pub project_update_health: Option, + /// `Internal` Initiative update health for new updates. + pub initiative_update_health: Option, + /// `Internal` Notifications with the same grouping key will be grouped together in the UI. + pub grouping_key: Option, + /// `Internal` Priority of the notification with the same grouping key. Higher number means higher priority. If priority is the same, notifications should be sorted by `createdAt`. + pub grouping_priority: Option, + /// The bot that caused the notification. + pub bot_actor: Option>, +} +impl GraphQLFields for Notification { + type FullType = Self; + fn selection() -> String { + "id createdAt updatedAt archivedAt type readAt emailedAt snoozedUntilAt unsnoozedAt category url inboxUrl title subtitle isLinearActor actorAvatarUrl actorInitials actorAvatarColor issueStatusType projectUpdateHealth initiativeUpdateHealth groupingKey groupingPriority" + .into() + } +} +/// Notification subscriptions for models. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct NotificationSubscription { + /// The unique identifier of the entity. + pub id: Option, + /// The time at which the entity was created. + pub created_at: Option>, + /// The last time at which the entity was meaningfully updated. This is the same as the creation time if the entity hasn't + /// been updated after creation. + pub updated_at: Option>, + /// The time at which the entity was archived. Null if the entity has not been archived. + pub archived_at: Option>, + /// The user that subscribed to receive notifications. + pub subscriber: Option>, + /// The customer associated with the notification subscription. + pub customer: Option>, + /// The contextual custom view associated with the notification subscription. + pub custom_view: Option>, + /// The contextual cycle view associated with the notification subscription. + pub cycle: Option>, + /// The contextual label view associated with the notification subscription. + pub label: Option>, + /// The contextual project view associated with the notification subscription. + pub project: Option>, + /// The contextual initiative view associated with the notification subscription. + pub initiative: Option>, + /// The team associated with the notification subscription. + pub team: Option>, + /// The user view associated with the notification subscription. + pub user: Option>, + /// The type of view to which the notification subscription context is associated with. + pub context_view_type: Option, + /// The type of user view to which the notification subscription context is associated with. + pub user_context_view_type: Option, + /// Whether the subscription is active or not. + pub active: Option, +} +impl GraphQLFields for NotificationSubscription { + type FullType = Self; + fn selection() -> String { + "id createdAt updatedAt archivedAt contextViewType userContextViewType active".into() + } +} /// `Internal` An access key for CI/CD integrations. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] @@ -5294,6 +5453,8 @@ pub struct IssueNotification { pub parent_comment: Option>, /// The team related to the issue notification. pub team: Option>, + /// The subscriptions related to the notification. + pub subscriptions: Option>>, } impl GraphQLFields for IssueNotification { type FullType = Self; @@ -5920,6 +6081,8 @@ pub struct NotificationArchivePayload { pub last_sync_id: Option, /// Whether the operation was successful. pub success: Option, + /// The archived/unarchived entity. Null if entity was deleted. + pub entity: Option>, } impl GraphQLFields for NotificationArchivePayload { type FullType = Self; @@ -5932,6 +6095,8 @@ impl GraphQLFields for NotificationArchivePayload { pub struct NotificationBatchActionPayload { /// The identifier of the last sync operation. pub last_sync_id: Option, + /// The notifications that were updated. + pub notifications: Option>>, /// Whether the operation was successful. pub success: Option, } @@ -6005,6 +6170,7 @@ impl GraphQLFields for NotificationChannelPreferences { #[serde(rename_all = "camelCase", default)] pub struct NotificationConnection { pub edges: Option>>, + pub nodes: Option>>, pub page_info: Option>, } impl GraphQLFields for NotificationConnection { @@ -6086,6 +6252,7 @@ impl GraphQLFields for NotificationDeliveryPreferencesSchedule { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct NotificationEdge { + pub node: Option>, /// Used in `before` and `after` args pub cursor: Option, } @@ -6100,6 +6267,8 @@ impl GraphQLFields for NotificationEdge { pub struct NotificationPayload { /// The identifier of the last sync operation. pub last_sync_id: Option, + /// The notification that was created or updated. + pub notification: Option>, /// Whether the operation was successful. pub success: Option, } @@ -6113,6 +6282,7 @@ impl GraphQLFields for NotificationPayload { #[serde(rename_all = "camelCase", default)] pub struct NotificationSubscriptionConnection { pub edges: Option>>, + pub nodes: Option>>, pub page_info: Option>, } impl GraphQLFields for NotificationSubscriptionConnection { @@ -6124,6 +6294,7 @@ impl GraphQLFields for NotificationSubscriptionConnection { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct NotificationSubscriptionEdge { + pub node: Option>, /// Used in `before` and `after` args pub cursor: Option, } @@ -6138,6 +6309,8 @@ impl GraphQLFields for NotificationSubscriptionEdge { pub struct NotificationSubscriptionPayload { /// The identifier of the last sync operation. pub last_sync_id: Option, + /// The notification subscription that was created or updated. + pub notification_subscription: Option>, /// Whether the operation was successful. pub success: Option, } diff --git a/crates/lineark-sdk/tests/online.rs b/crates/lineark-sdk/tests/online.rs index e5c4270..7effc16 100644 --- a/crates/lineark-sdk/tests/online.rs +++ b/crates/lineark-sdk/tests/online.rs @@ -1061,6 +1061,54 @@ mod online { client.team_delete(team_id).await.unwrap(); } + // ── Notifications ───────────────────────────────────────────────────── + + #[test_with::runtime_ignore_if(no_online_test_token)] + async fn notifications_list() { + let client = test_client(); + let conn = client + .notifications::() + .first(10) + .send() + .await + .unwrap(); + // Connection should deserialize; may be empty if no notifications. + let _ = conn.page_info; + for notification in &conn.nodes { + assert!(notification.id.is_some()); + } + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + async fn notification_archive_and_unarchive() { + let client = test_client(); + let conn = client + .notifications::() + .first(5) + .send() + .await + .unwrap(); + + if conn.nodes.is_empty() { + // No notifications to test with — skip gracefully. + return; + } + + let notification_id = conn.nodes[0].id.clone().unwrap(); + + // Archive the notification. + client + .notification_archive(notification_id.clone()) + .await + .unwrap(); + + // Unarchive the notification. + client + .notification_unarchive(notification_id) + .await + .unwrap(); + } + // ── Error handling ────────────────────────────────────────────────────── #[test_with::runtime_ignore_if(no_online_test_token)] diff --git a/crates/lineark/src/commands/mod.rs b/crates/lineark/src/commands/mod.rs index f18bd6b..9a508c6 100644 --- a/crates/lineark/src/commands/mod.rs +++ b/crates/lineark/src/commands/mod.rs @@ -6,6 +6,7 @@ pub mod helpers; pub mod issues; pub mod labels; pub mod milestones; +pub mod notifications; pub mod projects; pub mod relations; pub mod self_cmd; diff --git a/crates/lineark/src/commands/notifications.rs b/crates/lineark/src/commands/notifications.rs new file mode 100644 index 0000000..97fad14 --- /dev/null +++ b/crates/lineark/src/commands/notifications.rs @@ -0,0 +1,174 @@ +use clap::Args; +use lineark_sdk::generated::inputs::NotificationUpdateInput; +use lineark_sdk::generated::types::Notification; +use lineark_sdk::{Client, GraphQLFields}; +use serde::{Deserialize, Serialize}; +use tabled::Tabled; + +use crate::output::{self, Format}; + +/// Manage notifications. +#[derive(Debug, Args)] +pub struct NotificationsCmd { + #[command(subcommand)] + pub action: NotificationsAction, +} + +#[derive(Debug, clap::Subcommand)] +pub enum NotificationsAction { + /// List notifications. + /// + /// Examples: + /// lineark notifications list + /// lineark notifications list --unread + /// lineark notifications list -l 10 + List { + /// Maximum number of notifications to return (max 250). + #[arg(short = 'l', long, default_value = "50", value_parser = clap::value_parser!(i64).range(1..=250))] + limit: i64, + /// Show only unread notifications. + #[arg(long, default_value = "false")] + unread: bool, + }, + /// Show full details for a single notification. + /// + /// Examples: + /// lineark notifications read NOTIFICATION-UUID + Read { + /// Notification UUID. + id: String, + }, + /// Mark a notification as read. + /// + /// Examples: + /// lineark notifications mark-read NOTIFICATION-UUID + MarkRead { + /// Notification UUID. + id: String, + }, + /// Archive a notification. + /// + /// Examples: + /// lineark notifications archive NOTIFICATION-UUID + Archive { + /// Notification UUID. + id: String, + }, + /// Unarchive a notification. + /// + /// Examples: + /// lineark notifications unarchive NOTIFICATION-UUID + Unarchive { + /// Notification UUID. + id: String, + }, +} + +// ── Lean types ─────────────────────────────────────────────────────────────── + +/// Lean notification type for list views. +#[derive(Debug, Clone, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = Notification)] +#[serde(rename_all = "camelCase", default)] +struct NotificationSummary { + pub id: Option, + pub r#type: Option, + pub title: Option, + pub read_at: Option, + pub created_at: Option, + pub url: Option, +} + +// ── List row ───────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, Tabled)] +struct NotificationRow { + id: String, + #[tabled(rename = "type")] + r#type: String, + title: String, + read: String, + created_at: String, +} + +impl From<&NotificationSummary> for NotificationRow { + fn from(n: &NotificationSummary) -> Self { + Self { + id: n.id.clone().unwrap_or_default(), + r#type: n.r#type.clone().unwrap_or_default(), + title: n.title.clone().unwrap_or_default(), + read: if n.read_at.is_some() { "yes" } else { "no" }.to_string(), + created_at: n.created_at.clone().unwrap_or_default(), + } + } +} + +// ── Command dispatch ───────────────────────────────────────────────────────── + +pub async fn run(cmd: NotificationsCmd, client: &Client, format: Format) -> anyhow::Result<()> { + match cmd.action { + NotificationsAction::List { limit, unread } => { + let conn = client + .notifications::() + .first(limit) + .send() + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + let nodes: Vec<&NotificationSummary> = if unread { + conn.nodes.iter().filter(|n| n.read_at.is_none()).collect() + } else { + conn.nodes.iter().collect() + }; + + match format { + Format::Json => { + let json = serde_json::to_string_pretty(&nodes).unwrap_or_default(); + println!("{json}"); + } + Format::Human => { + let rows: Vec = + nodes.iter().map(|n| NotificationRow::from(*n)).collect(); + output::print_table(&rows, format); + } + } + } + NotificationsAction::Read { id } => { + let notification = client + .notification::(id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + output::print_one(¬ification, format); + } + NotificationsAction::MarkRead { id } => { + let input = NotificationUpdateInput { + read_at: Some(chrono::Utc::now()), + ..Default::default() + }; + + let result = client + .notification_update(input, id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&result, format); + } + NotificationsAction::Archive { id } => { + let result = client + .notification_archive(id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&result, format); + } + NotificationsAction::Unarchive { id } => { + let result = client + .notification_unarchive(id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&result, format); + } + } + Ok(()) +} diff --git a/crates/lineark/src/commands/usage.rs b/crates/lineark/src/commands/usage.rs index ccb11c8..4fd21c0 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -110,6 +110,11 @@ COMMANDS: --public only works for images (not SVG) lineark embeds download Download any file by URL (works with [--output PATH] [--overwrite] Linear CDN URLs and external URLs alike) + lineark notifications list [-l N] [--unread] List notifications + lineark notifications read Full notification detail + lineark notifications mark-read Mark a notification as read + lineark notifications archive Archive a notification + lineark notifications unarchive Unarchive a notification lineark self update Update lineark to the latest release lineark self update --check Check if an update is available diff --git a/crates/lineark/src/main.rs b/crates/lineark/src/main.rs index e8b2c39..65f4a70 100644 --- a/crates/lineark/src/main.rs +++ b/crates/lineark/src/main.rs @@ -47,6 +47,8 @@ enum Command { ProjectMilestones(commands::milestones::MilestonesCmd), /// Manage file embeds (download/upload). Embeds(commands::embeds::EmbedsCmd), + /// Manage notifications. + Notifications(commands::notifications::NotificationsCmd), /// Print a compact LLM-friendly command reference. Usage, /// Manage lineark itself (update, etc.). @@ -122,6 +124,7 @@ async fn main() { Command::Relations(cmd) => commands::relations::run(cmd, &client, format).await, Command::Documents(cmd) => commands::documents::run(cmd, &client, format).await, Command::Embeds(cmd) => commands::embeds::run(cmd, &client, format).await, + Command::Notifications(cmd) => commands::notifications::run(cmd, &client, format).await, Command::ProjectMilestones(cmd) => commands::milestones::run(cmd, &client, format).await, Command::Usage | Command::SelfCmd(_) => unreachable!(), }; diff --git a/crates/lineark/tests/offline.rs b/crates/lineark/tests/offline.rs index 313fd32..11da0c9 100644 --- a/crates/lineark/tests/offline.rs +++ b/crates/lineark/tests/offline.rs @@ -887,3 +887,68 @@ fn usage_includes_comments_delete() { .success() .stdout(predicate::str::contains("comments delete")); } + +// ── Notifications ──────────────────────────────────────────────────────────── + +#[test] +fn notifications_help_shows_subcommands() { + lineark() + .args(["notifications", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("list")) + .stdout(predicate::str::contains("read")) + .stdout(predicate::str::contains("mark-read")) + .stdout(predicate::str::contains("archive")) + .stdout(predicate::str::contains("unarchive")); +} + +#[test] +fn notifications_list_help_shows_flags() { + lineark() + .args(["notifications", "list", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--unread")) + .stdout(predicate::str::contains("--limit")); +} + +#[test] +fn notifications_mark_read_help_shows_id() { + lineark() + .args(["notifications", "mark-read", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("")); +} + +#[test] +fn notifications_archive_help_shows_id() { + lineark() + .args(["notifications", "archive", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("")); +} + +#[test] +fn notifications_unarchive_help_shows_id() { + lineark() + .args(["notifications", "unarchive", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("")); +} + +#[test] +fn usage_includes_notifications_commands() { + lineark() + .arg("usage") + .assert() + .success() + .stdout(predicate::str::contains("notifications list")) + .stdout(predicate::str::contains("notifications read")) + .stdout(predicate::str::contains("notifications mark-read")) + .stdout(predicate::str::contains("notifications archive")) + .stdout(predicate::str::contains("notifications unarchive")); +} diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index 9509f76..74c7069 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -3398,4 +3398,108 @@ mod online { let result: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!(result["success"].as_bool(), Some(true)); } + + // ── Notifications ──────────────────────────────────────────────────────── + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn notifications_list_returns_json() { + let token = api_token(); + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "notifications", + "list", + ]) + .output() + .expect("failed to execute lineark"); + assert!( + output.status.success(), + "notifications list should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let json: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("output should be valid JSON"); + assert!( + json.is_array(), + "notifications list JSON should be an array" + ); + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn notifications_archive_and_unarchive() { + let token = api_token(); + + // List notifications to find one to archive. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "notifications", + "list", + "-l", + "5", + ]) + .output() + .expect("failed to execute lineark"); + assert!( + output.status.success(), + "notifications list should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let json: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("output should be valid JSON"); + let arr = json.as_array().expect("should be an array"); + + if arr.is_empty() { + // No notifications — skip gracefully. + return; + } + + let notification_id = arr[0]["id"].as_str().expect("notification should have id"); + + // Archive the notification. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "notifications", + "archive", + notification_id, + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "notifications archive should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + + // Unarchive the notification. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "notifications", + "unarchive", + notification_id, + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "notifications unarchive should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + } } diff --git a/schema/operations.toml b/schema/operations.toml index bc16bda..6bb7d34 100644 --- a/schema/operations.toml +++ b/schema/operations.toml @@ -22,6 +22,10 @@ issueRelation = true projectMilestones = true projectMilestone = true +# Notifications +notifications = true +notification = true + [mutations] # Phase 2 — Core writes issueCreate = true @@ -53,3 +57,8 @@ teamUpdate = true teamDelete = true teamMembershipCreate = true teamMembershipDelete = true + +# Notifications +notificationUpdate = true +notificationArchive = true +notificationUnarchive = true