Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions crates/lineark-sdk/src/generated/client_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,4 +440,41 @@ impl Client {
) -> Result<T, LinearError> {
crate::generated::mutations::document_delete::<T>(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<FullType = super::types::Cycle>,
>(
&self,
input: CycleCreateInput,
) -> Result<T, LinearError> {
crate::generated::mutations::cycle_create::<T>(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<FullType = super::types::Cycle>,
>(
&self,
input: CycleUpdateInput,
id: String,
) -> Result<T, LinearError> {
crate::generated::mutations::cycle_update::<T>(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<FullType = super::types::Cycle>,
>(
&self,
id: String,
) -> Result<T, LinearError> {
crate::generated::mutations::cycle_archive::<T>(self, id).await
}
}
56 changes: 56 additions & 0 deletions crates/lineark-sdk/src/generated/mutations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -476,3 +476,59 @@ pub async fn document_delete<
.execute_mutation::<T>(&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<FullType = super::types::Cycle>,
>(
client: &Client,
input: CycleCreateInput,
) -> Result<T, LinearError> {
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::<T>(&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<FullType = super::types::Cycle>,
>(
client: &Client,
input: CycleUpdateInput,
id: String,
) -> Result<T, LinearError> {
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::<T>(&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<FullType = super::types::Cycle>,
>(
client: &Client,
id: String,
) -> Result<T, LinearError> {
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::<T>(&query, variables, "cycleArchive", "entity")
.await
}
68 changes: 68 additions & 0 deletions crates/lineark-sdk/tests/online.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Cycle>(id).await;
}
});
})
.join();
}
}

test_with::tokio_runner!(online);

#[test_with::module]
Expand Down Expand Up @@ -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::<Team>().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::<Cycle>(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::<Cycle>(update_input, cycle_id.clone())
.await
.unwrap();
assert!(updated.id.is_some());

// Archive the cycle.
let archived = client.cycle_archive::<Cycle>(cycle_id).await.unwrap();
assert!(archived.id.is_some());
}

// ── Error handling ──────────────────────────────────────────────────────

#[test_with::runtime_ignore_if(no_online_test_token)]
Expand Down
152 changes: 149 additions & 3 deletions crates/lineark/src/commands/cycles.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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.
///
Expand Down Expand Up @@ -50,6 +52,57 @@ pub enum CyclesAction {
#[arg(long)]
team: Option<String>,
},
/// 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<String>,
/// Cycle description.
#[arg(long)]
description: Option<String>,
},
/// 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<String>,
/// New cycle description.
#[arg(long)]
description: Option<String>,
/// New start date (YYYY-MM-DD).
#[arg(long)]
starts_at: Option<String>,
/// New end date (YYYY-MM-DD).
#[arg(long)]
ends_at: Option<String>,
},
/// Archive a cycle.
///
/// Examples:
/// lineark cycles archive CYCLE-UUID
Archive {
/// Cycle UUID.
id: String,
},
}

#[derive(Debug, Serialize, Tabled)]
Expand All @@ -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<String>,
name: Option<String>,
number: Option<f64>,
starts_at: Option<String>,
ends_at: Option<String>,
}

fn cycle_status_label(cycle: &Cycle) -> String {
if cycle.is_active.unwrap_or(false) {
"active".to_string()
Expand All @@ -78,6 +143,17 @@ fn cycle_status_label(cycle: &Cycle) -> String {
}
}

/// Parse a YYYY-MM-DD string into a `DateTime<Utc>` at midnight UTC.
fn parse_date_to_utc(s: &str, field_name: &str) -> anyhow::Result<chrono::DateTime<Utc>> {
let date = s
.parse::<NaiveDate>()
.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 {
Expand Down Expand Up @@ -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::<CycleRef>(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::<CycleRef>(input, id)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;

output::print_one(&cycle, format);
}
CyclesAction::Archive { id } => {
let cycle = client
.cycle_archive::<CycleRef>(id)
.await
.map_err(|e| anyhow::anyhow!("{}", e))?;

output::print_one(&cycle, format);
}
}
Ok(())
}
Expand Down
7 changes: 7 additions & 0 deletions crates/lineark/src/commands/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ COMMANDS:
[--active] Only the active cycle
[--around-active N] Active ± N neighbors
lineark cycles read <ID> [--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 <ID> Update a cycle
[--name TEXT] [--description TEXT] Name, description
[--starts-at DATE] [--ends-at DATE] Dates (YYYY-MM-DD)
lineark cycles archive <ID> 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
Expand Down
Loading