diff --git a/crates/lineark-sdk/src/generated/client_impl.rs b/crates/lineark-sdk/src/generated/client_impl.rs index 1a13719..0047e54 100644 --- a/crates/lineark-sdk/src/generated/client_impl.rs +++ b/crates/lineark-sdk/src/generated/client_impl.rs @@ -124,6 +124,23 @@ impl Client { pub fn issue_labels(&self) -> IssueLabelsQueryBuilder<'_, T> { crate::generated::queries::issue_labels(self) } + /// All initiatives in the workspace. + /// + /// Full type: [`Initiative`](super::types::Initiative) + pub fn initiatives(&self) -> InitiativesQueryBuilder<'_, T> { + crate::generated::queries::initiatives(self) + } + /// One specific initiative. + /// + /// Full type: [`Initiative`](super::types::Initiative) + pub async fn initiative< + T: DeserializeOwned + GraphQLFields, + >( + &self, + id: String, + ) -> Result { + crate::generated::queries::initiative::(self, id).await + } /// All documents in the workspace. /// /// Full type: [`Document`](super::types::Document) @@ -182,6 +199,25 @@ impl Client { ) -> Result { crate::generated::mutations::image_upload_from_url(self, url).await } + /// Creates a new initiativeToProject join. + /// + /// Full type: [`InitiativeToProject`](super::types::InitiativeToProject) + pub async fn initiative_to_project_create< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, + >( + &self, + input: InitiativeToProjectCreateInput, + ) -> Result { + crate::generated::mutations::initiative_to_project_create::(self, input).await + } + /// Deletes a initiativeToProject. + pub async fn initiative_to_project_delete( + &self, + id: String, + ) -> Result { + crate::generated::mutations::initiative_to_project_delete(self, id).await + } /// Creates a new comment. /// /// Full type: [`Comment`](super::types::Comment) @@ -403,6 +439,59 @@ impl Client { ) -> Result { crate::generated::mutations::issue_relation_delete(self, id).await } + /// Creates a new initiative. + /// + /// Full type: [`Initiative`](super::types::Initiative) + pub async fn initiative_create< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, + >( + &self, + input: InitiativeCreateInput, + ) -> Result { + crate::generated::mutations::initiative_create::(self, input).await + } + /// Updates a initiative. + /// + /// Full type: [`Initiative`](super::types::Initiative) + pub async fn initiative_update< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, + >( + &self, + input: InitiativeUpdateInput, + id: String, + ) -> Result { + crate::generated::mutations::initiative_update::(self, input, id).await + } + /// Archives a initiative. + /// + /// Full type: [`Initiative`](super::types::Initiative) + pub async fn initiative_archive< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, + >( + &self, + id: String, + ) -> Result { + crate::generated::mutations::initiative_archive::(self, id).await + } + /// Unarchives a initiative. + /// + /// Full type: [`Initiative`](super::types::Initiative) + pub async fn initiative_unarchive< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, + >( + &self, + id: String, + ) -> Result { + crate::generated::mutations::initiative_unarchive::(self, id).await + } + /// Deletes (trashes) an initiative. + pub async fn initiative_delete(&self, id: String) -> Result { + crate::generated::mutations::initiative_delete(self, id).await + } /// Creates a new document. /// /// Full type: [`Document`](super::types::Document) diff --git a/crates/lineark-sdk/src/generated/mutations.rs b/crates/lineark-sdk/src/generated/mutations.rs index 97c4ec2..d217a2c 100644 --- a/crates/lineark-sdk/src/generated/mutations.rs +++ b/crates/lineark-sdk/src/generated/mutations.rs @@ -49,6 +49,44 @@ pub async fn image_upload_from_url( .execute::(&query, variables, "imageUploadFromUrl") .await } +/// Creates a new initiativeToProject join. +/// +/// Full type: [`InitiativeToProject`](super::types::InitiativeToProject) +pub async fn initiative_to_project_create< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, +>( + client: &Client, + input: InitiativeToProjectCreateInput, +) -> Result { + let variables = serde_json::json!({ "input" : input }); + let query = String::from( + "mutation InitiativeToProjectCreate($input: InitiativeToProjectCreateInput!) { initiativeToProjectCreate(input: $input) { success initiativeToProject { ", + ) + &T::selection() + " } } }"; + client + .execute_mutation::( + &query, + variables, + "initiativeToProjectCreate", + "initiativeToProject", + ) + .await +} +/// Deletes a initiativeToProject. +pub async fn initiative_to_project_delete( + client: &Client, + id: String, +) -> Result { + let variables = serde_json::json!({ "id" : id }); + let response_parts: Vec = vec!["success".to_string(), "entityId".to_string()]; + let query = String::from( + "mutation InitiativeToProjectDelete($id: String!) { initiativeToProjectDelete(id: $id) { ", + ) + &response_parts.join(" ") + + " } }"; + client + .execute::(&query, variables, "initiativeToProjectDelete") + .await +} /// Creates a new comment. /// /// Full type: [`Comment`](super::types::Comment) @@ -420,6 +458,95 @@ pub async fn issue_relation_delete( .execute::(&query, variables, "issueRelationDelete") .await } +/// Creates a new initiative. +/// +/// Full type: [`Initiative`](super::types::Initiative) +pub async fn initiative_create< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, +>( + client: &Client, + input: InitiativeCreateInput, +) -> Result { + let variables = serde_json::json!({ "input" : input }); + let query = String::from( + "mutation InitiativeCreate($input: InitiativeCreateInput!) { initiativeCreate(input: $input) { success initiative { ", + ) + &T::selection() + " } } }"; + client + .execute_mutation::(&query, variables, "initiativeCreate", "initiative") + .await +} +/// Updates a initiative. +/// +/// Full type: [`Initiative`](super::types::Initiative) +pub async fn initiative_update< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, +>( + client: &Client, + input: InitiativeUpdateInput, + id: String, +) -> Result { + let variables = serde_json::json!({ "input" : input, "id" : id }); + let query = String::from( + "mutation InitiativeUpdate($input: InitiativeUpdateInput!, $id: String!) { initiativeUpdate(input: $input, id: $id) { success initiative { ", + ) + &T::selection() + " } } }"; + client + .execute_mutation::(&query, variables, "initiativeUpdate", "initiative") + .await +} +/// Archives a initiative. +/// +/// Full type: [`Initiative`](super::types::Initiative) +pub async fn initiative_archive< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, +>( + client: &Client, + id: String, +) -> Result { + let variables = serde_json::json!({ "id" : id }); + let query = String::from( + "mutation InitiativeArchive($id: String!) { initiativeArchive(id: $id) { success entity { ", + ) + &T::selection() + + " } } }"; + client + .execute_mutation::(&query, variables, "initiativeArchive", "entity") + .await +} +/// Unarchives a initiative. +/// +/// Full type: [`Initiative`](super::types::Initiative) +pub async fn initiative_unarchive< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, +>( + client: &Client, + id: String, +) -> Result { + let variables = serde_json::json!({ "id" : id }); + let query = String::from( + "mutation InitiativeUnarchive($id: String!) { initiativeUnarchive(id: $id) { success entity { ", + ) + &T::selection() + " } } }"; + client + .execute_mutation::(&query, variables, "initiativeUnarchive", "entity") + .await +} +/// Deletes (trashes) an initiative. +pub async fn initiative_delete( + client: &Client, + id: String, +) -> Result { + let variables = serde_json::json!({ "id" : id }); + let response_parts: Vec = vec!["success".to_string(), "entityId".to_string()]; + let query = + String::from("mutation InitiativeDelete($id: String!) { initiativeDelete(id: $id) { ") + + &response_parts.join(" ") + + " } }"; + client + .execute::(&query, variables, "initiativeDelete") + .await +} /// Creates a new document. /// /// Full type: [`Document`](super::types::Document) diff --git a/crates/lineark-sdk/src/generated/queries.rs b/crates/lineark-sdk/src/generated/queries.rs index 542e21a..42b01aa 100644 --- a/crates/lineark-sdk/src/generated/queries.rs +++ b/crates/lineark-sdk/src/generated/queries.rs @@ -837,6 +837,101 @@ impl<'a, T: DeserializeOwned + GraphQLFields { + client: &'a Client, + filter: Option, + before: Option, + after: Option, + first: Option, + last: Option, + include_archived: Option, + order_by: Option, + sort: Option, + _marker: std::marker::PhantomData, +} +impl<'a, T: DeserializeOwned + GraphQLFields> + InitiativesQueryBuilder<'a, T> +{ + pub fn filter(mut self, value: InitiativeFilter) -> 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 fn sort(mut self, value: InitiativeSortInput) -> Self { + self.sort = 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)); + } + if let Some(ref v) = self.sort { + map.insert("sort".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 }} }} }}", + "Initiatives", + "$filter: InitiativeFilter, $before: String, $after: String, $first: Int, $last: Int, $includeArchived: Boolean, $orderBy: PaginationOrderBy, $sort: [InitiativeSortInput!]", + "initiatives", + "filter: $filter, before: $before, after: $after, first: $first, last: $last, includeArchived: $includeArchived, orderBy: $orderBy, sort: $sort", + selection + ); + self.client + .execute_connection::(&query, variables, "initiatives") + .await + } +} /// Query builder: All documents in the workspace. /// /// Full type: [`Document`](super::types::Document) @@ -1258,6 +1353,40 @@ pub fn issue_labels<'a, T>(client: &'a Client) -> IssueLabelsQueryBuilder<'a, T> _marker: std::marker::PhantomData, } } +/// All initiatives in the workspace. +/// +/// Full type: [`Initiative`](super::types::Initiative) +pub fn initiatives<'a, T>(client: &'a Client) -> InitiativesQueryBuilder<'a, T> { + InitiativesQueryBuilder { + client, + filter: None, + before: None, + after: None, + first: None, + last: None, + include_archived: None, + order_by: None, + sort: None, + _marker: std::marker::PhantomData, + } +} +/// One specific initiative. +/// +/// Full type: [`Initiative`](super::types::Initiative) +pub async fn initiative< + T: DeserializeOwned + GraphQLFields, +>( + client: &Client, + id: String, +) -> Result { + let variables = serde_json::json!({ "id" : id }); + let selection = T::selection(); + let query = format!( + "query {}({}) {{ {}({}) {{ {} }} }}", + "Initiative", "$id: String!", "initiative", "id: $id", selection + ); + client.execute::(&query, variables, "initiative").await +} /// All documents in the workspace. /// /// Full type: [`Document`](super::types::Document) diff --git a/crates/lineark-sdk/tests/online.rs b/crates/lineark-sdk/tests/online.rs index e5c4270..1ee02b8 100644 --- a/crates/lineark-sdk/tests/online.rs +++ b/crates/lineark-sdk/tests/online.rs @@ -95,6 +95,48 @@ impl Drop for DocumentGuard { } } +/// RAII guard — deletes an initiative on drop. +struct InitiativeGuard { + token: String, + id: String, +} + +impl Drop for InitiativeGuard { + fn drop(&mut self) { + let token = self.token.clone(); + let id = self.id.clone(); + let _ = std::thread::spawn(move || { + tokio::runtime::Runtime::new().unwrap().block_on(async { + if let Ok(client) = Client::from_token(token) { + let _ = client.initiative_delete(id).await; + } + }); + }) + .join(); + } +} + +/// RAII guard — permanently deletes a project on drop. +struct ProjectGuard { + token: String, + id: String, +} + +impl Drop for ProjectGuard { + fn drop(&mut self) { + let token = self.token.clone(); + let id = self.id.clone(); + let _ = std::thread::spawn(move || { + tokio::runtime::Runtime::new().unwrap().block_on(async { + if let Ok(client) = Client::from_token(token) { + let _ = client.project_delete::(id).await; + } + }); + }) + .join(); + } +} + test_with::tokio_runner!(online); #[test_with::module] @@ -1069,4 +1111,115 @@ mod online { let result = client.whoami::().await; assert!(result.is_err(), "invalid token should produce an error"); } + + // ── Initiatives ───────────────────────────────────────────────────────── + + #[test_with::runtime_ignore_if(no_online_test_token)] + async fn initiative_create_update_and_delete() { + use lineark_sdk::generated::inputs::{InitiativeCreateInput, InitiativeUpdateInput}; + + let client = test_client(); + + // Create an initiative. + let input = InitiativeCreateInput { + name: Some("[test] SDK initiative_create_update_and_delete".to_string()), + description: Some("Automated test — will be deleted immediately.".to_string()), + ..Default::default() + }; + let entity = client.initiative_create::(input).await.unwrap(); + let initiative_id = entity.id.clone().unwrap(); + let _initiative_guard = InitiativeGuard { + token: test_token(), + id: initiative_id.clone(), + }; + assert!(!initiative_id.is_empty()); + + // Update the initiative. + let update_input = InitiativeUpdateInput { + name: Some("[test] SDK initiative — updated".to_string()), + ..Default::default() + }; + let updated = client + .initiative_update::(update_input, initiative_id.clone()) + .await + .unwrap(); + assert!(updated.id.is_some()); + + // Read the initiative by ID. + let fetched = client + .initiative::(initiative_id.clone()) + .await + .unwrap(); + assert_eq!(fetched.id, Some(initiative_id.clone())); + assert_eq!( + fetched.name, + Some("[test] SDK initiative — updated".to_string()) + ); + + // Delete the initiative. + client.initiative_delete(initiative_id).await.unwrap(); + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + async fn initiative_to_project_create_and_delete() { + use lineark_sdk::generated::inputs::{ + InitiativeCreateInput, InitiativeToProjectCreateInput, ProjectCreateInput, + }; + + let client = test_client(); + + // Create an initiative. + let init_input = InitiativeCreateInput { + name: Some("[test] SDK initiative_to_project".to_string()), + ..Default::default() + }; + let initiative = client + .initiative_create::(init_input) + .await + .unwrap(); + let initiative_id = initiative.id.clone().unwrap(); + let _initiative_guard = InitiativeGuard { + token: test_token(), + id: initiative_id.clone(), + }; + + // Create a project. + let teams = client.teams::().first(1).send().await.unwrap(); + let team_id = teams.nodes[0].id.clone().unwrap(); + + let proj_input = ProjectCreateInput { + name: Some("[test] SDK initiative link project".to_string()), + team_ids: Some(vec![team_id]), + ..Default::default() + }; + let project = client + .project_create::(None, proj_input) + .await + .unwrap(); + let project_id = project.id.clone().unwrap(); + let _project_guard = ProjectGuard { + token: test_token(), + id: project_id.clone(), + }; + + // Link the project to the initiative. + let link_input = InitiativeToProjectCreateInput { + initiative_id: Some(initiative_id.clone()), + project_id: Some(project_id.clone()), + ..Default::default() + }; + let join = client + .initiative_to_project_create::(link_input) + .await + .unwrap(); + let join_id = join.id.clone().unwrap(); + assert!(!join_id.is_empty()); + + // Delete the link. + client.initiative_to_project_delete(join_id).await.unwrap(); + + // Clean up. + client.initiative_delete(initiative_id).await.unwrap(); + client.project_delete::(project_id).await.unwrap(); + } } diff --git a/crates/lineark/src/commands/helpers.rs b/crates/lineark/src/commands/helpers.rs index 13afef2..cf921d5 100644 --- a/crates/lineark/src/commands/helpers.rs +++ b/crates/lineark/src/commands/helpers.rs @@ -1,4 +1,6 @@ -use lineark_sdk::generated::types::{Cycle, IssueLabel, IssueSearchResult, Project, Team, User}; +use lineark_sdk::generated::types::{ + Cycle, Initiative, IssueLabel, IssueSearchResult, Project, Team, User, +}; use lineark_sdk::Client; /// Resolve a team key or name (e.g., "ENG" or "Engineering") to a team UUID. @@ -379,3 +381,49 @@ pub async fn resolve_cycle_id( available.join(", ") )) } + +/// Resolve an initiative name or UUID to an initiative UUID. +/// If the input already looks like a UUID, return it as-is. +/// Matches case-insensitively on `name`. +pub async fn resolve_initiative_id(client: &Client, name_or_id: &str) -> anyhow::Result { + if uuid::Uuid::parse_str(name_or_id).is_ok() { + return Ok(name_or_id.to_string()); + } + let conn = client + .initiatives::() + .first(250) + .include_archived(true) + .send() + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + let matches: Vec<&Initiative> = conn + .nodes + .iter() + .filter(|i| { + i.name + .as_deref() + .is_some_and(|n| n.eq_ignore_ascii_case(name_or_id)) + }) + .collect(); + + match matches.len() { + 0 => { + let available: Vec = conn.nodes.iter().filter_map(|i| i.name.clone()).collect(); + Err(anyhow::anyhow!( + "Initiative '{}' not found. Available: {}", + name_or_id, + available.join(", ") + )) + } + 1 => Ok(matches[0].id.clone().unwrap_or_default()), + _ => { + let names: Vec = matches.iter().filter_map(|i| i.name.clone()).collect(); + Err(anyhow::anyhow!( + "Ambiguous initiative '{}'. Matches: {}", + name_or_id, + names.join(", ") + )) + } + } +} diff --git a/crates/lineark/src/commands/initiatives.rs b/crates/lineark/src/commands/initiatives.rs new file mode 100644 index 0000000..1af1670 --- /dev/null +++ b/crates/lineark/src/commands/initiatives.rs @@ -0,0 +1,507 @@ +use clap::Args; +use lineark_sdk::generated::enums::InitiativeStatus; +use lineark_sdk::generated::inputs::{ + InitiativeCreateInput, InitiativeToProjectCreateInput, InitiativeUpdateInput, +}; +use lineark_sdk::generated::types::{ + Initiative, InitiativeToProject, InitiativeToProjectConnection, Project, ProjectConnection, + User, +}; +use lineark_sdk::{Client, GraphQLFields}; +use serde::{Deserialize, Serialize}; +use tabled::Tabled; + +use super::helpers::{resolve_initiative_id, resolve_project_id, resolve_user_id_or_me}; +use crate::output::{self, Format}; + +/// Parse an initiative status string into the generated enum. +fn parse_initiative_status(s: &str) -> anyhow::Result { + match s.to_lowercase().as_str() { + "planned" => Ok(InitiativeStatus::Planned), + "active" => Ok(InitiativeStatus::Active), + "completed" => Ok(InitiativeStatus::Completed), + _ => Err(anyhow::anyhow!( + "Invalid initiative status '{}'. Valid values: Planned, Active, Completed", + s + )), + } +} + +/// Manage initiatives. +#[derive(Debug, Args)] +pub struct InitiativesCmd { + #[command(subcommand)] + pub action: InitiativesAction, +} + +#[derive(Debug, clap::Subcommand)] +#[allow(clippy::large_enum_variant)] +pub enum InitiativesAction { + /// List all initiatives. + /// + /// Examples: + /// lineark initiatives list + /// lineark initiatives list --limit 10 + List { + /// Maximum number of initiatives to return. + #[arg(short = 'l', long, default_value = "50")] + limit: i64, + }, + /// Show full details for a single initiative. + /// + /// Examples: + /// lineark initiatives read "Q1 Goals" + /// lineark initiatives read INITIATIVE-UUID + Read { + /// Initiative name or UUID. + id: String, + }, + /// Create a new initiative. + /// + /// Examples: + /// lineark initiatives create "Q1 Goals" + /// lineark initiatives create "Q1 Goals" --status Active --owner me + /// lineark initiatives create "Launch v2" --description "Ship v2 by Q2" --target-date 2026-06-30 + Create { + /// Initiative name. + name: String, + /// Initiative description (markdown). + #[arg(short = 'd', long)] + description: Option, + /// Owner: user name, display name, UUID, or `me`. + #[arg(long)] + owner: Option, + /// Status: Planned, Active, or Completed. + #[arg(long)] + status: Option, + /// Estimated completion date (YYYY-MM-DD). + #[arg(long)] + target_date: Option, + /// Initiative color (hex code). + #[arg(long)] + color: Option, + /// Initiative icon (emoji or icon name). + #[arg(long)] + icon: Option, + }, + /// Update an existing initiative. + /// + /// Examples: + /// lineark initiatives update "Q1 Goals" --status Active + /// lineark initiatives update INITIATIVE-UUID --name "Q2 Goals" --owner me + Update { + /// Initiative name or UUID. + id: String, + /// New initiative name. + #[arg(long)] + name: Option, + /// New description (markdown). + #[arg(short = 'd', long)] + description: Option, + /// New owner: user name, display name, UUID, or `me`. + #[arg(long)] + owner: Option, + /// New status: Planned, Active, or Completed. + #[arg(long)] + status: Option, + /// New estimated completion date (YYYY-MM-DD). + #[arg(long)] + target_date: Option, + }, + /// Archive an initiative. + /// + /// Examples: + /// lineark initiatives archive "Q1 Goals" + /// lineark initiatives archive INITIATIVE-UUID + Archive { + /// Initiative name or UUID. + id: String, + }, + /// Unarchive a previously archived initiative. + /// + /// Examples: + /// lineark initiatives unarchive "Q1 Goals" + /// lineark initiatives unarchive INITIATIVE-UUID + Unarchive { + /// Initiative name or UUID. + id: String, + }, + /// Delete an initiative. + /// + /// Examples: + /// lineark initiatives delete "Q1 Goals" + /// lineark initiatives delete INITIATIVE-UUID + Delete { + /// Initiative name or UUID. + id: String, + }, + /// Manage project links for an initiative. + Projects { + #[command(subcommand)] + action: ProjectsAction, + }, +} + +#[derive(Debug, clap::Subcommand)] +pub enum ProjectsAction { + /// Link a project to an initiative. + /// + /// Examples: + /// lineark initiatives projects add "Q1 Goals" --project "Mobile App UX" + /// lineark initiatives projects add INITIATIVE-UUID --project PROJECT-UUID + Add { + /// Initiative name or UUID. + initiative: String, + /// Project name or UUID to link. + #[arg(long)] + project: String, + }, + /// Unlink a project from an initiative. + /// + /// Examples: + /// lineark initiatives projects remove "Q1 Goals" --project "Mobile App UX" + /// lineark initiatives projects remove INITIATIVE-UUID --project PROJECT-UUID + Remove { + /// Initiative name or UUID. + initiative: String, + /// Project name or UUID to unlink. + #[arg(long)] + project: String, + }, +} + +// ── List row ───────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, Tabled)] +pub struct InitiativeRow { + pub id: String, + pub name: String, + pub status: String, + pub target_date: String, +} + +/// Lean type for `initiatives list`. +#[derive(Debug, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = Initiative)] +#[serde(rename_all = "camelCase", default)] +struct InitiativeSummary { + id: Option, + name: Option, + status: Option, + target_date: Option, +} + +// ── Read detail ────────────────────────────────────────────────────────── + +/// Full initiative detail for `initiatives read`. +#[derive(Debug, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = Initiative)] +#[serde(rename_all = "camelCase", default)] +struct InitiativeDetail { + id: Option, + name: Option, + description: Option, + status: Option, + target_date: Option, + color: Option, + icon: Option, + url: Option, + created_at: Option>, + updated_at: Option>, + archived_at: Option>, + #[graphql(nested)] + owner: Option, + #[graphql(nested)] + projects: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = User)] +#[serde(rename_all = "camelCase", default)] +struct OwnerRef { + id: Option, + name: Option, + display_name: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = ProjectConnection)] +#[serde(rename_all = "camelCase", default)] +struct InitiativeProjectsConnection { + #[graphql(nested)] + nodes: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = Project)] +#[serde(rename_all = "camelCase", default)] +struct InitiativeProjectRef { + id: Option, + name: Option, + slug_id: Option, +} + +// ── Mutation result ────────────────────────────────────────────────────── + +/// Lean result type for initiative mutations. +#[derive(Debug, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = Initiative)] +#[serde(rename_all = "camelCase", default)] +struct InitiativeRef { + id: Option, + name: Option, + status: Option, +} + +/// Lean result type for initiative-to-project mutations. +#[derive(Debug, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = InitiativeToProject)] +#[serde(rename_all = "camelCase", default)] +struct InitiativeToProjectRef { + id: Option, +} + +/// Lean type for querying a project's initiative links (used by remove). +#[derive(Debug, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = Project)] +#[serde(rename_all = "camelCase", default)] +struct ProjectWithInitiativeLinks { + id: Option, + #[graphql(nested)] + initiative_to_projects: ProjectInitiativeToProjectConnection, +} + +#[derive(Debug, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = InitiativeToProjectConnection)] +#[serde(rename_all = "camelCase", default)] +struct ProjectInitiativeToProjectConnection { + #[graphql(nested)] + nodes: Vec, +} + +#[derive(Debug, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = InitiativeToProject)] +#[serde(rename_all = "camelCase", default)] +struct InitiativeToProjectNode { + id: Option, + #[graphql(nested)] + initiative: InitiativeIdRef, +} + +#[derive(Debug, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = Initiative)] +#[serde(rename_all = "camelCase", default)] +struct InitiativeIdRef { + id: Option, +} + +// ── Command dispatch ──────────────────────────────────────────────────── + +pub async fn run(cmd: InitiativesCmd, client: &Client, format: Format) -> anyhow::Result<()> { + match cmd.action { + InitiativesAction::List { limit } => { + let conn = client + .initiatives::() + .first(limit) + .send() + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + let rows: Vec = conn + .nodes + .iter() + .map(|i| InitiativeRow { + id: i.id.clone().unwrap_or_default(), + name: i.name.clone().unwrap_or_default(), + status: i + .status + .as_ref() + .map(|s| format!("{:?}", s)) + .unwrap_or_default(), + target_date: i.target_date.map(|d| d.to_string()).unwrap_or_default(), + }) + .collect(); + + output::print_table(&rows, format); + } + InitiativesAction::Read { id } => { + let initiative_id = resolve_initiative_id(client, &id).await?; + let initiative = client + .initiative::(initiative_id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + output::print_one(&initiative, format); + } + InitiativesAction::Create { + name, + description, + owner, + status, + target_date, + color, + icon, + } => { + let owner_id = match owner { + Some(ref o) => Some(resolve_user_id_or_me(client, o).await?), + None => None, + }; + + let parsed_status = status.map(|s| parse_initiative_status(&s)).transpose()?; + + let target_date = target_date + .map(|d| d.parse::()) + .transpose() + .map_err(|e| anyhow::anyhow!("Invalid target-date (expected YYYY-MM-DD): {}", e))?; + + let input = InitiativeCreateInput { + name: Some(name), + description, + owner_id, + status: parsed_status, + target_date, + color, + icon, + ..Default::default() + }; + + let initiative = client + .initiative_create::(input) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&initiative, format); + } + InitiativesAction::Update { + id, + name, + description, + owner, + status, + target_date, + } => { + if name.is_none() + && description.is_none() + && owner.is_none() + && status.is_none() + && target_date.is_none() + { + return Err(anyhow::anyhow!( + "No update fields provided. Use --name, --description, --owner, --status, or --target-date." + )); + } + + let initiative_id = resolve_initiative_id(client, &id).await?; + + let owner_id = match owner { + Some(ref o) => Some(resolve_user_id_or_me(client, o).await?), + None => None, + }; + + let parsed_status = status.map(|s| parse_initiative_status(&s)).transpose()?; + + let target_date = target_date + .map(|d| d.parse::()) + .transpose() + .map_err(|e| anyhow::anyhow!("Invalid target-date (expected YYYY-MM-DD): {}", e))?; + + let input = InitiativeUpdateInput { + name, + description, + owner_id, + status: parsed_status, + target_date, + ..Default::default() + }; + + let initiative = client + .initiative_update::(input, initiative_id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&initiative, format); + } + InitiativesAction::Archive { id } => { + let initiative_id = resolve_initiative_id(client, &id).await?; + + let initiative = client + .initiative_archive::(initiative_id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&initiative, format); + } + InitiativesAction::Unarchive { id } => { + let initiative_id = resolve_initiative_id(client, &id).await?; + + let initiative = client + .initiative_unarchive::(initiative_id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&initiative, format); + } + InitiativesAction::Delete { id } => { + let initiative_id = resolve_initiative_id(client, &id).await?; + + let result = client + .initiative_delete(initiative_id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&result, format); + } + InitiativesAction::Projects { action } => match action { + ProjectsAction::Add { + initiative, + project, + } => { + let initiative_id = resolve_initiative_id(client, &initiative).await?; + let project_id = resolve_project_id(client, &project).await?; + + let input = InitiativeToProjectCreateInput { + initiative_id: Some(initiative_id), + project_id: Some(project_id), + ..Default::default() + }; + + let join = client + .initiative_to_project_create::(input) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&join, format); + } + ProjectsAction::Remove { + initiative, + project, + } => { + let initiative_id = resolve_initiative_id(client, &initiative).await?; + let project_id = resolve_project_id(client, &project).await?; + + // Query the project's initiative links to find the join entity ID. + let proj = client + .project::(project_id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + let join_id = proj + .initiative_to_projects + .nodes + .iter() + .find(|n| n.initiative.id.as_deref() == Some(&initiative_id)) + .and_then(|n| n.id.clone()) + .ok_or_else(|| { + anyhow::anyhow!("No link found between this initiative and project") + })?; + + let result = client + .initiative_to_project_delete(join_id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&result, format); + } + }, + } + Ok(()) +} diff --git a/crates/lineark/src/commands/mod.rs b/crates/lineark/src/commands/mod.rs index f18bd6b..65b94fc 100644 --- a/crates/lineark/src/commands/mod.rs +++ b/crates/lineark/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod cycles; pub mod documents; pub mod embeds; pub mod helpers; +pub mod initiatives; pub mod issues; pub mod labels; pub mod milestones; diff --git a/crates/lineark/src/commands/usage.rs b/crates/lineark/src/commands/usage.rs index ccb11c8..aa35e40 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -104,6 +104,23 @@ COMMANDS: [--target-date DATE] [--description TEXT] lineark project-milestones delete Delete a milestone [--project NAME-OR-ID] + lineark initiatives list [-l N] List all initiatives + lineark initiatives read Full initiative detail (owner, projects, dates) + lineark initiatives create Create an initiative + [--description TEXT] [--owner NAME-OR-ID|me] Description, initiative owner + [--status Planned|Active|Completed] Status + [--target-date DATE] [--color HEX] [--icon IC] Target date, color, icon + lineark initiatives update Update an initiative + [--name TEXT] [--description TEXT] Name, description + [--owner NAME-OR-ID|me] [--status STATUS] Owner, status + [--target-date DATE] Target date + lineark initiatives archive Archive an initiative + lineark initiatives unarchive Unarchive an initiative + lineark initiatives delete Delete an initiative + lineark initiatives projects add Link a project to an initiative + --project NAME-OR-ID + lineark initiatives projects remove Unlink a project from an initiative + --project NAME-OR-ID lineark embeds upload [--public] Upload file to Linear, returns asset URL Embed as markdown [name](url) in issues, comments, or documents diff --git a/crates/lineark/src/main.rs b/crates/lineark/src/main.rs index e8b2c39..b16ee3e 100644 --- a/crates/lineark/src/main.rs +++ b/crates/lineark/src/main.rs @@ -31,6 +31,8 @@ enum Command { Users(commands::users::UsersCmd), /// Manage projects. Projects(commands::projects::ProjectsCmd), + /// Manage initiatives. + Initiatives(commands::initiatives::InitiativesCmd), /// Manage issue labels. Labels(commands::labels::LabelsCmd), /// Manage cycles. @@ -115,6 +117,7 @@ async fn main() { Command::Teams(cmd) => commands::teams::run(cmd, &client, format).await, Command::Users(cmd) => commands::users::run(cmd, &client, format).await, Command::Projects(cmd) => commands::projects::run(cmd, &client, format).await, + Command::Initiatives(cmd) => commands::initiatives::run(cmd, &client, format).await, Command::Labels(cmd) => commands::labels::run(cmd, &client, format).await, Command::Cycles(cmd) => commands::cycles::run(cmd, &client, format).await, Command::Issues(cmd) => commands::issues::run(cmd, &client, format).await, diff --git a/crates/lineark/tests/offline.rs b/crates/lineark/tests/offline.rs index 313fd32..3875f2a 100644 --- a/crates/lineark/tests/offline.rs +++ b/crates/lineark/tests/offline.rs @@ -887,3 +887,74 @@ fn usage_includes_comments_delete() { .success() .stdout(predicate::str::contains("comments delete")); } + +// ── Initiatives ────────────────────────────────────────────────────────────── + +#[test] +fn initiatives_help_shows_subcommands() { + lineark() + .args(["initiatives", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("list")) + .stdout(predicate::str::contains("read")) + .stdout(predicate::str::contains("create")) + .stdout(predicate::str::contains("update")) + .stdout(predicate::str::contains("archive")) + .stdout(predicate::str::contains("unarchive")) + .stdout(predicate::str::contains("delete")) + .stdout(predicate::str::contains("projects")); +} + +#[test] +fn initiatives_create_help_shows_flags() { + lineark() + .args(["initiatives", "create", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--description")) + .stdout(predicate::str::contains("--owner")) + .stdout(predicate::str::contains("--status")); +} + +#[test] +fn initiatives_update_no_flags_prints_error() { + lineark() + .args([ + "--api-token", + "fake-token", + "initiatives", + "update", + "some-uuid", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("No update fields provided")); +} + +#[test] +fn initiatives_projects_add_help_shows_flags() { + lineark() + .args(["initiatives", "projects", "add", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--project")) + .stdout(predicate::str::contains("")); +} + +#[test] +fn usage_includes_initiatives_commands() { + lineark() + .arg("usage") + .assert() + .success() + .stdout(predicate::str::contains("initiatives list")) + .stdout(predicate::str::contains("initiatives read")) + .stdout(predicate::str::contains("initiatives create")) + .stdout(predicate::str::contains("initiatives update")) + .stdout(predicate::str::contains("initiatives archive")) + .stdout(predicate::str::contains("initiatives unarchive")) + .stdout(predicate::str::contains("initiatives delete")) + .stdout(predicate::str::contains("initiatives projects add")) + .stdout(predicate::str::contains("initiatives projects remove")); +} diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index 9509f76..20ce0fd 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -132,6 +132,24 @@ impl Drop for ProjectGuard { } } +/// RAII guard — deletes an initiative on drop. +struct InitiativeGuard { + token: String, + id: String, +} + +impl Drop for InitiativeGuard { + fn drop(&mut self) { + let Ok(client) = Client::from_token(self.token.clone()) else { + return; + }; + let id = self.id.clone(); + let _ = tokio::runtime::Runtime::new() + .unwrap() + .block_on(async { client.initiative_delete(id).await }); + } +} + test_with::runner!(online); #[test_with::module] @@ -3398,4 +3416,295 @@ mod online { let result: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!(result["success"].as_bool(), Some(true)); } + + // ── Initiatives ───────────────────────────────────────────────────────── + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn initiatives_create_update_and_delete() { + let token = api_token(); + + // Create an initiative. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "initiatives", + "create", + "[test] CLI initiative CRUD", + "--description", + "Automated CLI test initiative.", + "--status", + "Planned", + ]) + .output() + .expect("failed to execute lineark"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "initiatives create should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let created: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let initiative_id = created["id"] + .as_str() + .expect("created initiative should have id") + .to_string(); + let _initiative_guard = InitiativeGuard { + token: token.clone(), + id: initiative_id.clone(), + }; + assert!(created.get("name").is_some()); + + // Update the initiative. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "initiatives", + "update", + &initiative_id, + "--name", + "[test] CLI initiative CRUD — updated", + "--status", + "Active", + ]) + .output() + .expect("failed to execute lineark"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "initiatives update should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let updated: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!( + updated.get("id").is_some(), + "update response should contain id" + ); + + // Delete the initiative. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "initiatives", + "delete", + &initiative_id, + ]) + .output() + .expect("failed to execute lineark"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "initiatives delete should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn initiatives_archive_and_unarchive() { + let token = api_token(); + + // Create an initiative. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "initiatives", + "create", + "[test] CLI initiative archive", + ]) + .output() + .expect("failed to execute lineark"); + assert!( + output.status.success(), + "initiative creation should succeed" + ); + let created: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let initiative_id = created["id"].as_str().unwrap().to_string(); + let _initiative_guard = InitiativeGuard { + token: token.clone(), + id: initiative_id.clone(), + }; + + // Archive the initiative. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "initiatives", + "archive", + &initiative_id, + ]) + .output() + .expect("failed to execute lineark"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "initiatives archive should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let archived: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!( + archived.get("id").is_some(), + "archive response should contain id" + ); + + // Unarchive the initiative. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "initiatives", + "unarchive", + &initiative_id, + ]) + .output() + .expect("failed to execute lineark"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "initiatives unarchive should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let unarchived: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!( + unarchived.get("id").is_some(), + "unarchive response should contain id" + ); + + // Clean up: delete the initiative. + let client = Client::from_token(api_token()).unwrap(); + tokio::runtime::Runtime::new().unwrap().block_on(async { + client.initiative_delete(initiative_id).await.unwrap(); + }); + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn initiatives_projects_add_and_remove() { + let token = api_token(); + + // Create an initiative. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "initiatives", + "create", + "[test] CLI initiative projects", + ]) + .output() + .expect("failed to execute lineark"); + assert!( + output.status.success(), + "initiative creation should succeed" + ); + let created: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let initiative_id = created["id"].as_str().unwrap().to_string(); + let _initiative_guard = InitiativeGuard { + token: token.clone(), + id: initiative_id.clone(), + }; + + // Create a project to link. + let client = Client::from_token(api_token()).unwrap(); + let teams_conn = tokio::runtime::Runtime::new().unwrap().block_on(async { + client + .teams::() + .first(1) + .send() + .await + .unwrap() + }); + let team_id = teams_conn.nodes[0].id.clone().unwrap(); + + let project_input = ProjectCreateInput { + name: Some("[test] CLI initiative link project".to_string()), + team_ids: Some(vec![team_id]), + ..Default::default() + }; + let project = tokio::runtime::Runtime::new().unwrap().block_on(async { + client + .project_create::(None, project_input) + .await + .unwrap() + }); + let project_id = project.id.clone().unwrap(); + let _project_guard = ProjectGuard { + token: token.clone(), + id: project_id.clone(), + }; + + // Link the project to the initiative. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "initiatives", + "projects", + "add", + &initiative_id, + "--project", + &project_id, + ]) + .output() + .expect("failed to execute lineark"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "initiatives projects add should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let add_result: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!( + add_result.get("id").is_some(), + "add result should contain join entity id" + ); + + // Unlink the project from the initiative. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "initiatives", + "projects", + "remove", + &initiative_id, + "--project", + &project_id, + ]) + .output() + .expect("failed to execute lineark"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "initiatives projects remove should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + + // Clean up: delete initiative and project. + let client = Client::from_token(api_token()).unwrap(); + tokio::runtime::Runtime::new().unwrap().block_on(async { + client.initiative_delete(initiative_id).await.unwrap(); + client.project_delete::(project_id).await.unwrap(); + }); + } } diff --git a/schema/operations.toml b/schema/operations.toml index bc16bda..2e72ff8 100644 --- a/schema/operations.toml +++ b/schema/operations.toml @@ -22,6 +22,10 @@ issueRelation = true projectMilestones = true projectMilestone = true +# Phase 4 — Initiatives +initiatives = true +initiative = true + [mutations] # Phase 2 — Core writes issueCreate = true @@ -53,3 +57,12 @@ teamUpdate = true teamDelete = true teamMembershipCreate = true teamMembershipDelete = true + +# Phase 4 — Initiatives +initiativeCreate = true +initiativeUpdate = true +initiativeArchive = true +initiativeUnarchive = true +initiativeDelete = true +initiativeToProjectCreate = true +initiativeToProjectDelete = true