diff --git a/crates/lineark-sdk/src/generated/client_impl.rs b/crates/lineark-sdk/src/generated/client_impl.rs index 1a13719..493a55d 100644 --- a/crates/lineark-sdk/src/generated/client_impl.rs +++ b/crates/lineark-sdk/src/generated/client_impl.rs @@ -440,4 +440,41 @@ impl Client { ) -> Result { crate::generated::mutations::document_delete::(self, id).await } + /// Creates a new cycle. + /// + /// Full type: [`Cycle`](super::types::Cycle) + pub async fn cycle_create< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, + >( + &self, + input: CycleCreateInput, + ) -> Result { + crate::generated::mutations::cycle_create::(self, input).await + } + /// Updates a cycle. + /// + /// Full type: [`Cycle`](super::types::Cycle) + pub async fn cycle_update< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, + >( + &self, + input: CycleUpdateInput, + id: String, + ) -> Result { + crate::generated::mutations::cycle_update::(self, input, id).await + } + /// Archives a cycle. + /// + /// Full type: [`Cycle`](super::types::Cycle) + pub async fn cycle_archive< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, + >( + &self, + id: String, + ) -> Result { + crate::generated::mutations::cycle_archive::(self, id).await + } } diff --git a/crates/lineark-sdk/src/generated/mutations.rs b/crates/lineark-sdk/src/generated/mutations.rs index 97c4ec2..29142d3 100644 --- a/crates/lineark-sdk/src/generated/mutations.rs +++ b/crates/lineark-sdk/src/generated/mutations.rs @@ -476,3 +476,59 @@ pub async fn document_delete< .execute_mutation::(&query, variables, "documentDelete", "entity") .await } +/// Creates a new cycle. +/// +/// Full type: [`Cycle`](super::types::Cycle) +pub async fn cycle_create< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, +>( + client: &Client, + input: CycleCreateInput, +) -> Result { + let variables = serde_json::json!({ "input" : input }); + let query = String::from( + "mutation CycleCreate($input: CycleCreateInput!) { cycleCreate(input: $input) { success cycle { ", + ) + &T::selection() + " } } }"; + client + .execute_mutation::(&query, variables, "cycleCreate", "cycle") + .await +} +/// Updates a cycle. +/// +/// Full type: [`Cycle`](super::types::Cycle) +pub async fn cycle_update< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, +>( + client: &Client, + input: CycleUpdateInput, + id: String, +) -> Result { + let variables = serde_json::json!({ "input" : input, "id" : id }); + let query = String::from( + "mutation CycleUpdate($input: CycleUpdateInput!, $id: String!) { cycleUpdate(input: $input, id: $id) { success cycle { ", + ) + &T::selection() + " } } }"; + client + .execute_mutation::(&query, variables, "cycleUpdate", "cycle") + .await +} +/// Archives a cycle. +/// +/// Full type: [`Cycle`](super::types::Cycle) +pub async fn cycle_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 CycleArchive($id: String!) { cycleArchive(id: $id) { success entity { ", + ) + &T::selection() + + " } } }"; + client + .execute_mutation::(&query, variables, "cycleArchive", "entity") + .await +} diff --git a/crates/lineark-sdk/tests/online.rs b/crates/lineark-sdk/tests/online.rs index e5c4270..8a1defd 100644 --- a/crates/lineark-sdk/tests/online.rs +++ b/crates/lineark-sdk/tests/online.rs @@ -95,6 +95,27 @@ impl Drop for DocumentGuard { } } +/// RAII guard — archives a cycle on drop (cycles cannot be deleted, only archived). +struct CycleGuard { + token: String, + id: String, +} + +impl Drop for CycleGuard { + 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.cycle_archive::(id).await; + } + }); + }) + .join(); + } +} + test_with::tokio_runner!(online); #[test_with::module] @@ -1061,6 +1082,53 @@ mod online { client.team_delete(team_id).await.unwrap(); } + // ── Cycle CRUD ──────────────────────────────────────────────────────── + + #[test_with::runtime_ignore_if(no_online_test_token)] + async fn cycle_create_update_and_archive() { + use lineark_sdk::generated::inputs::{CycleCreateInput, CycleUpdateInput}; + + let client = test_client(); + + // Get the first team to create a cycle in. + let teams = client.teams::().first(1).send().await.unwrap(); + let team_id = teams.nodes[0].id.clone().unwrap(); + + // Create a cycle with future dates. + let starts_at = chrono::Utc::now() + chrono::Duration::days(365); + let ends_at = starts_at + chrono::Duration::days(14); + + let input = CycleCreateInput { + team_id: Some(team_id), + starts_at: Some(starts_at), + ends_at: Some(ends_at), + name: Some("[test] SDK cycle_create_update_and_archive".to_string()), + ..Default::default() + }; + let cycle = client.cycle_create::(input).await.unwrap(); + let cycle_id = cycle.id.clone().unwrap(); + let _cycle_guard = CycleGuard { + token: test_token(), + id: cycle_id.clone(), + }; + assert!(!cycle_id.is_empty()); + + // Update the cycle's name. + let update_input = CycleUpdateInput { + name: Some("[test] SDK cycle — updated".to_string()), + ..Default::default() + }; + let updated = client + .cycle_update::(update_input, cycle_id.clone()) + .await + .unwrap(); + assert!(updated.id.is_some()); + + // Archive the cycle. + let archived = client.cycle_archive::(cycle_id).await.unwrap(); + assert!(archived.id.is_some()); + } + // ── Error handling ────────────────────────────────────────────────────── #[test_with::runtime_ignore_if(no_online_test_token)] diff --git a/crates/lineark/src/commands/cycles.rs b/crates/lineark/src/commands/cycles.rs index d07b2c4..7d14012 100644 --- a/crates/lineark/src/commands/cycles.rs +++ b/crates/lineark/src/commands/cycles.rs @@ -1,8 +1,9 @@ +use chrono::{NaiveDate, TimeZone, Utc}; use clap::Args; -use lineark_sdk::generated::inputs::CycleFilter; +use lineark_sdk::generated::inputs::{CycleCreateInput, CycleFilter, CycleUpdateInput}; use lineark_sdk::generated::types::Cycle; -use lineark_sdk::Client; -use serde::Serialize; +use lineark_sdk::{Client, GraphQLFields}; +use serde::{Deserialize, Serialize}; use tabled::Tabled; use super::helpers::resolve_team_id; @@ -16,6 +17,7 @@ pub struct CyclesCmd { } #[derive(Debug, clap::Subcommand)] +#[allow(clippy::large_enum_variant)] pub enum CyclesAction { /// List cycles. By default shows all cycles; use --active to filter. /// @@ -50,6 +52,57 @@ pub enum CyclesAction { #[arg(long)] team: Option, }, + /// Create a new cycle. + /// + /// Examples: + /// lineark cycles create --team ENG --starts-at 2026-03-10 --ends-at 2026-03-24 + /// lineark cycles create --team ENG --starts-at 2026-04-01 --ends-at 2026-04-14 --name "Sprint 5" + Create { + /// Team key, name, or UUID. + #[arg(long)] + team: String, + /// Start date (YYYY-MM-DD). + #[arg(long)] + starts_at: String, + /// End date (YYYY-MM-DD). + #[arg(long)] + ends_at: String, + /// Custom cycle name. + #[arg(long)] + name: Option, + /// Cycle description. + #[arg(long)] + description: Option, + }, + /// Update an existing cycle. + /// + /// Examples: + /// lineark cycles update CYCLE-UUID --name "Sprint 5 (revised)" + /// lineark cycles update CYCLE-UUID --starts-at 2026-03-11 --ends-at 2026-03-25 + Update { + /// Cycle UUID. + id: String, + /// New cycle name. + #[arg(long)] + name: Option, + /// New cycle description. + #[arg(long)] + description: Option, + /// New start date (YYYY-MM-DD). + #[arg(long)] + starts_at: Option, + /// New end date (YYYY-MM-DD). + #[arg(long)] + ends_at: Option, + }, + /// Archive a cycle. + /// + /// Examples: + /// lineark cycles archive CYCLE-UUID + Archive { + /// Cycle UUID. + id: String, + }, } #[derive(Debug, Serialize, Tabled)] @@ -62,6 +115,18 @@ pub struct CycleRow { pub active: String, } +/// Lean result type for cycle mutations. +#[derive(Debug, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = Cycle)] +#[serde(rename_all = "camelCase", default)] +struct CycleRef { + id: Option, + name: Option, + number: Option, + starts_at: Option, + ends_at: Option, +} + fn cycle_status_label(cycle: &Cycle) -> String { if cycle.is_active.unwrap_or(false) { "active".to_string() @@ -78,6 +143,17 @@ fn cycle_status_label(cycle: &Cycle) -> String { } } +/// Parse a YYYY-MM-DD string into a `DateTime` at midnight UTC. +fn parse_date_to_utc(s: &str, field_name: &str) -> anyhow::Result> { + let date = s + .parse::() + .map_err(|e| anyhow::anyhow!("Invalid {} (expected YYYY-MM-DD): {}", field_name, e))?; + let dt = date + .and_hms_opt(0, 0, 0) + .ok_or_else(|| anyhow::anyhow!("Invalid {} datetime", field_name))?; + Ok(Utc.from_utc_datetime(&dt)) +} + pub async fn run(cmd: CyclesCmd, client: &Client, format: Format) -> anyhow::Result<()> { match cmd.action { CyclesAction::List { @@ -197,6 +273,76 @@ pub async fn run(cmd: CyclesCmd, client: &Client, format: Format) -> anyhow::Res output::print_one(&full_cycle, format); } + CyclesAction::Create { + team, + starts_at, + ends_at, + name, + description, + } => { + let team_id = resolve_team_id(client, &team).await?; + let starts_at_dt = parse_date_to_utc(&starts_at, "starts-at")?; + let ends_at_dt = parse_date_to_utc(&ends_at, "ends-at")?; + + let input = CycleCreateInput { + team_id: Some(team_id), + starts_at: Some(starts_at_dt), + ends_at: Some(ends_at_dt), + name, + description, + ..Default::default() + }; + + let cycle = client + .cycle_create::(input) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&cycle, format); + } + CyclesAction::Update { + id, + name, + description, + starts_at, + ends_at, + } => { + if name.is_none() && description.is_none() && starts_at.is_none() && ends_at.is_none() { + return Err(anyhow::anyhow!( + "No update fields provided. Use --name, --description, --starts-at, or --ends-at." + )); + } + + let starts_at_dt = starts_at + .map(|s| parse_date_to_utc(&s, "starts-at")) + .transpose()?; + let ends_at_dt = ends_at + .map(|s| parse_date_to_utc(&s, "ends-at")) + .transpose()?; + + let input = CycleUpdateInput { + name, + description, + starts_at: starts_at_dt, + ends_at: ends_at_dt, + ..Default::default() + }; + + let cycle = client + .cycle_update::(input, id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&cycle, format); + } + CyclesAction::Archive { id } => { + let cycle = client + .cycle_archive::(id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&cycle, format); + } } Ok(()) } diff --git a/crates/lineark/src/commands/usage.rs b/crates/lineark/src/commands/usage.rs index ccb11c8..2e4b289 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -53,6 +53,13 @@ COMMANDS: [--active] Only the active cycle [--around-active N] Active ± N neighbors lineark cycles read [--team KEY] Read cycle (UUID, name, or number) + lineark cycles create --team KEY Create a cycle + --starts-at DATE --ends-at DATE Dates (YYYY-MM-DD) + [--name TEXT] [--description TEXT] Optional name and description + lineark cycles update Update a cycle + [--name TEXT] [--description TEXT] Name, description + [--starts-at DATE] [--ends-at DATE] Dates (YYYY-MM-DD) + lineark cycles archive Archive a cycle lineark issues list [-l N] [--team KEY] Active issues (done/canceled hidden), newest first [--mine] Only issues assigned to me [--show-done] Include done/canceled issues diff --git a/crates/lineark/tests/offline.rs b/crates/lineark/tests/offline.rs index 313fd32..315b320 100644 --- a/crates/lineark/tests/offline.rs +++ b/crates/lineark/tests/offline.rs @@ -254,7 +254,10 @@ fn cycles_help_shows_subcommands() { .assert() .success() .stdout(predicate::str::contains("list")) - .stdout(predicate::str::contains("read")); + .stdout(predicate::str::contains("read")) + .stdout(predicate::str::contains("create")) + .stdout(predicate::str::contains("update")) + .stdout(predicate::str::contains("archive")); } #[test] @@ -278,6 +281,41 @@ fn cycles_read_help_shows_team_flag() { .stdout(predicate::str::contains("--team")); } +#[test] +fn cycles_create_help_shows_flags() { + lineark() + .args(["cycles", "create", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--team")) + .stdout(predicate::str::contains("--starts-at")) + .stdout(predicate::str::contains("--ends-at")); +} + +#[test] +fn cycles_update_no_flags_prints_error() { + lineark() + .args([ + "--api-token", + "fake-token", + "cycles", + "update", + "00000000-0000-0000-0000-000000000000", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("No update fields provided")); +} + +#[test] +fn cycles_archive_help_shows_id() { + lineark() + .args(["cycles", "archive", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("")); +} + // ── Usage includes Phase 3 commands ───────────────────────────────────────── #[test] @@ -313,6 +351,17 @@ fn usage_includes_cycles_flags() { .stdout(predicate::str::contains("--around-active")); } +#[test] +fn usage_includes_cycles_crud() { + lineark() + .arg("usage") + .assert() + .success() + .stdout(predicate::str::contains("cycles create")) + .stdout(predicate::str::contains("cycles update")) + .stdout(predicate::str::contains("cycles archive")); +} + // ── Labels ────────────────────────────────────────────────────────────────── #[test] diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index 9509f76..46964dc 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -5,7 +5,7 @@ use assert_cmd::Command; use lineark_sdk::generated::inputs::ProjectCreateInput; -use lineark_sdk::generated::types::{Issue, IssueRelation, Project}; +use lineark_sdk::generated::types::{Cycle, Issue, IssueRelation, Project}; use lineark_sdk::Client; use predicates::prelude::*; @@ -132,6 +132,24 @@ impl Drop for ProjectGuard { } } +/// RAII guard — archives a cycle on drop (cycles cannot be deleted, only archived). +struct CycleGuard { + token: String, + id: String, +} + +impl Drop for CycleGuard { + 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.cycle_archive::(id).await }); + } +} + test_with::runner!(online); #[test_with::module] @@ -3398,4 +3416,107 @@ mod online { let result: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!(result["success"].as_bool(), Some(true)); } + + // ── Cycles CRUD ────────────────────────────────────────────────────────── + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn cycles_create_update_and_archive() { + let token = api_token(); + + // Get the first team key. + let output = lineark() + .args(["--api-token", &token, "--format", "json", "teams", "list"]) + .output() + .expect("failed to execute lineark"); + let teams: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let team_key = teams[0]["key"].as_str().unwrap().to_string(); + + // Create a cycle with future dates. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "cycles", + "create", + "--team", + &team_key, + "--starts-at", + "2027-06-01", + "--ends-at", + "2027-06-14", + "--name", + "[test] CLI cycle CRUD", + ]) + .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(), + "cycles create should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let created: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let cycle_id = created["id"] + .as_str() + .expect("created cycle should have id") + .to_string(); + let _cycle_guard = CycleGuard { + token: token.clone(), + id: cycle_id.clone(), + }; + + // Update the cycle name. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "cycles", + "update", + &cycle_id, + "--name", + "[test] CLI cycle CRUD — updated", + ]) + .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(), + "cycles 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" + ); + + // Archive the cycle. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "cycles", + "archive", + &cycle_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(), + "cycles 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" + ); + } } diff --git a/schema/operations.toml b/schema/operations.toml index bc16bda..4654ef1 100644 --- a/schema/operations.toml +++ b/schema/operations.toml @@ -53,3 +53,6 @@ teamUpdate = true teamDelete = true teamMembershipCreate = true teamMembershipDelete = true +cycleCreate = true +cycleUpdate = true +cycleArchive = true