diff --git a/README.md b/README.md index 7bba754..e3d0947 100644 --- a/README.md +++ b/README.md @@ -70,15 +70,16 @@ That's it. Your agent discovers all commands at runtime by running `lineark usag | Area | Commands | |------|----------| -| **Issues** | `list`, `read`, `search`, `create`, `update`, `archive`, `unarchive`, `delete` | -| **Comments** | `create`, `delete` | +| **Issues** | `list`, `read`, `search`, `find-branch`, `create`, `update`, `batch-update`, `archive`, `unarchive`, `delete` | +| **Comments** | `create`, `update`, `resolve`, `unresolve`, `delete` | | **Relations** | `create` (blocks, blocked-by, related, duplicate, similar), `delete` | +| **Labels** | `list`, `create`, `update`, `delete` (groups, parent labels, team-scoped) | | **Projects** | `list`, `read`, `create` | | **Milestones** | `list`, `read`, `create`, `update`, `delete` | | **Cycles** | `list`, `read` | | **Documents** | `list`, `read`, `create`, `update`, `delete` | | **Teams** | `list`, `read`, `create`, `update`, `delete`, `members add`, `members remove` | -| **Users / Labels** | `list` | +| **Users** | `list` | | **File embeds** | `upload`, `download` | Every command supports `--help` for full details. Most flags accept human-readable names — `--team ENG`, `--assignee "Jane Doe"`, `--labels "Bug,P0"` — no UUIDs required. diff --git a/crates/lineark-sdk/README.md b/crates/lineark-sdk/README.md index 0ee0efa..35f9865 100644 --- a/crates/lineark-sdk/README.md +++ b/crates/lineark-sdk/README.md @@ -164,11 +164,21 @@ let payload = client.issue_create::(IssueCreateInput { |--------|-------------| | `issue_create(input)` | Create an issue | | `issue_update(input, id)` | Update an issue | +| `issue_batch_update(input, ids)` | Batch update multiple issues | | `issue_archive(trash, id)` | Archive an issue | | `issue_unarchive(id)` | Unarchive a previously archived issue | | `issue_delete(permanently, id)` | Delete an issue | +| `issue_vcs_branch_search(branch)` | Find issue by Git branch name | | `comment_create(input)` | Create a comment | +| `comment_update(input, id)` | Update a comment | +| `comment_resolve(input, id)` | Resolve a comment thread | +| `comment_unresolve(id)` | Unresolve a comment thread | | `comment_delete(id)` | Delete a comment | +| `issue_label_create(input)` | Create an issue label | +| `issue_label_update(input, id)` | Update an issue label | +| `issue_label_delete(id)` | Delete an issue label | +| `issue_relation_create(override_created_at, input)` | Create an issue relation | +| `issue_relation_delete(id)` | Delete an issue relation | | `document_create(input)` | Create a document | | `document_update(input, id)` | Update a document | | `document_delete(id)` | Delete a document | @@ -183,8 +193,6 @@ let payload = client.issue_create::(IssueCreateInput { | `team_delete(id)` | Delete a team | | `team_membership_create(input)` | Create a team membership | | `team_membership_delete(also_leave_parent_teams, id)` | Delete a team membership | -| `issue_relation_create(override_created_at, input)` | Create an issue relation | -| `issue_relation_delete(id)` | Delete an issue relation | | `file_upload(meta, public, size, type, name)` | Request a signed upload URL | | `image_upload_from_url(url)` | Upload image from URL | diff --git a/crates/lineark-sdk/src/generated/client_impl.rs b/crates/lineark-sdk/src/generated/client_impl.rs index 69b4d61..08d155c 100644 --- a/crates/lineark-sdk/src/generated/client_impl.rs +++ b/crates/lineark-sdk/src/generated/client_impl.rs @@ -466,6 +466,38 @@ impl Client { ) -> Result { crate::generated::mutations::issue_relation_delete(self, id).await } + /// Creates a new label. + /// + /// Full type: [`IssueLabel`](super::types::IssueLabel) + pub async fn issue_label_create< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, + >( + &self, + replace_team_labels: Option, + input: IssueLabelCreateInput, + ) -> Result { + crate::generated::mutations::issue_label_create::(self, replace_team_labels, input).await + } + /// Updates a label. + /// + /// Full type: [`IssueLabel`](super::types::IssueLabel) + pub async fn issue_label_update< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, + >( + &self, + replace_team_labels: Option, + input: IssueLabelUpdateInput, + id: String, + ) -> Result { + crate::generated::mutations::issue_label_update::(self, replace_team_labels, input, id) + .await + } + /// Deletes an issue label. + pub async fn issue_label_delete(&self, id: String) -> Result { + crate::generated::mutations::issue_label_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 7b128b7..c55e6f1 100644 --- a/crates/lineark-sdk/src/generated/mutations.rs +++ b/crates/lineark-sdk/src/generated/mutations.rs @@ -501,6 +501,64 @@ pub async fn issue_relation_delete( .execute::(&query, variables, "issueRelationDelete") .await } +/// Creates a new label. +/// +/// Full type: [`IssueLabel`](super::types::IssueLabel) +pub async fn issue_label_create< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, +>( + client: &Client, + replace_team_labels: Option, + input: IssueLabelCreateInput, +) -> Result { + let variables = serde_json::json!( + { "replaceTeamLabels" : replace_team_labels, "input" : input } + ); + let query = String::from( + "mutation IssueLabelCreate($replaceTeamLabels: Boolean, $input: IssueLabelCreateInput!) { issueLabelCreate(replaceTeamLabels: $replaceTeamLabels, input: $input) { success issueLabel { ", + ) + &T::selection() + " } } }"; + client + .execute_mutation::(&query, variables, "issueLabelCreate", "issueLabel") + .await +} +/// Updates a label. +/// +/// Full type: [`IssueLabel`](super::types::IssueLabel) +pub async fn issue_label_update< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, +>( + client: &Client, + replace_team_labels: Option, + input: IssueLabelUpdateInput, + id: String, +) -> Result { + let variables = serde_json::json!( + { "replaceTeamLabels" : replace_team_labels, "input" : input, "id" : id } + ); + let query = String::from( + "mutation IssueLabelUpdate($replaceTeamLabels: Boolean, $input: IssueLabelUpdateInput!, $id: String!) { issueLabelUpdate(replaceTeamLabels: $replaceTeamLabels, input: $input, id: $id) { success issueLabel { ", + ) + &T::selection() + " } } }"; + client + .execute_mutation::(&query, variables, "issueLabelUpdate", "issueLabel") + .await +} +/// Deletes an issue label. +pub async fn issue_label_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 IssueLabelDelete($id: String!) { issueLabelDelete(id: $id) { ") + + &response_parts.join(" ") + + " } }"; + client + .execute::(&query, variables, "issueLabelDelete") + .await +} /// Creates a new document. /// /// Full type: [`Document`](super::types::Document) diff --git a/crates/lineark-sdk/tests/online.rs b/crates/lineark-sdk/tests/online.rs index d7d48ba..d6dd339 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 — deletes an issue label on drop. +struct LabelGuard { + token: String, + id: String, +} + +impl Drop for LabelGuard { + 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.issue_label_delete(id).await; + } + }); + }) + .join(); + } +} + /// Helper: create a fresh test team and return its ID + RAII guard. async fn create_test_team(client: &Client) -> (String, TeamGuard) { use lineark_sdk::generated::inputs::TeamCreateInput; @@ -228,6 +249,51 @@ mod online { } } + #[test_with::runtime_ignore_if(no_online_test_token)] + async fn issue_label_create_update_and_delete() { + use lineark_sdk::generated::inputs::{IssueLabelCreateInput, IssueLabelUpdateInput}; + + let client = test_client(); + + // Create a workspace-level label with a unique name. + let unique = format!( + "[test] sdk-label {}", + &uuid::Uuid::new_v4().to_string()[..8] + ); + let input = IssueLabelCreateInput { + name: Some(unique.clone()), + color: Some("#eb5757".to_string()), + ..Default::default() + }; + let label = client + .issue_label_create::(None, input) + .await + .unwrap(); + let label_id = label.id.clone().unwrap(); + let _label_guard = LabelGuard { + token: test_token(), + id: label_id.clone(), + }; + assert!(!label_id.is_empty()); + assert_eq!(label.name, Some(unique)); + assert_eq!(label.color, Some("#eb5757".to_string())); + + // Update the label's color. + let update_input = IssueLabelUpdateInput { + color: Some("#4ea7fc".to_string()), + ..Default::default() + }; + let updated = client + .issue_label_update::(None, update_input, label_id.clone()) + .await + .unwrap(); + assert!(updated.id.is_some()); + assert_eq!(updated.color, Some("#4ea7fc".to_string())); + + // Delete the label. + client.issue_label_delete(label_id).await.unwrap(); + } + // ── Cycles ────────────────────────────────────────────────────────────── #[test_with::runtime_ignore_if(no_online_test_token)] diff --git a/crates/lineark/README.md b/crates/lineark/README.md index 799fdd7..8d7799a 100644 --- a/crates/lineark/README.md +++ b/crates/lineark/README.md @@ -55,26 +55,45 @@ lineark projects create --team KEY Create a project [--members NAME,...|me] Project members (comma-separated) [--start-date DATE] [--target-date DATE] Priority, content, icon, color [-p 0-4] [--content TEXT] ... See --help for all options -lineark labels list [--team KEY] List issue labels +lineark labels list [--team KEY] List labels (group, team, parent) +lineark labels create Create a label + [--team KEY] [--color HEX] Team, color + [--description TEXT] Description + [--parent-label-group ID] Nest under a group label + [--make-label-group] Create as a group label +lineark labels update Update a label + [--name TEXT] [--color HEX] Name, color + [--parent-label-group ID] Nest under a group label + [--clear-parent-label-group] Remove parent group + [--make-label-group] [--clear-label-group] Promote/demote group +lineark labels delete Delete a label lineark cycles list [-l N] [--team KEY] List cycles [--active] [--around-active N] Active cycle / ± N neighbors lineark cycles read [--team KEY] Read cycle (UUID, name, number) lineark issues list [-l N] [--team KEY] Active issues, newest first [--mine] [--show-done] Filter by assignee / state lineark issues read Full issue detail incl. sub-issues & comments +lineark issues find-branch Find issue by Git branch name lineark issues search [-l N] Full-text search [--team KEY] [--assignee NAME-OR-ID|me] Filter by team, assignee, status [--status NAME,...] [--show-done] lineark issues create --team KEY Create an issue - [-p 0-4] [--assignee NAME-OR-ID|me] Priority, assignee, labels, status - [--labels NAME,...] [-s NAME] ... Project, cycle — see --help + [-p 0-4] [-e N] [--assignee NAME-OR-ID|me] Priority, estimate, assignee + [--labels NAME,...] [-s NAME] ... Labels, status — see --help lineark issues update <IDENTIFIER> Update an issue - [-s NAME] [-p 0-4] [--assignee NAME-OR-ID|me] Status, priority, assignee + [-s NAME] [-p 0-4] [-e N] Status, priority, estimate + [--assignee NAME-OR-ID|me] Assignee [--clear-parent] [--project NAME-OR-ID] ... See --help for all options +lineark issues batch-update ID [ID ...] Batch update multiple issues + [-s NAME] [-p 0-4] [--assignee NAME-OR-ID|me] Status, priority, assignee lineark issues archive <IDENTIFIER> Archive an issue lineark issues unarchive <IDENTIFIER> Unarchive an issue lineark issues delete <IDENTIFIER> Delete (trash) an issue lineark comments create <ISSUE-ID> --body TEXT Comment on an issue +lineark comments update <COMMENT-UUID> Update a comment + --body TEXT New body in markdown +lineark comments resolve <COMMENT-UUID> Resolve a comment thread +lineark comments unresolve <COMMENT-UUID> Unresolve a comment thread lineark comments delete <ID> Delete a comment lineark relations create <ISSUE> Create an issue relation --blocks <ISSUE> Source blocks target diff --git a/crates/lineark/src/commands/helpers.rs b/crates/lineark/src/commands/helpers.rs index 13afef2..657cd61 100644 --- a/crates/lineark/src/commands/helpers.rs +++ b/crates/lineark/src/commands/helpers.rs @@ -240,22 +240,52 @@ pub async fn resolve_label_ids( return Ok(names_or_ids.to_vec()); } - // Fetch labels, optionally filtered by team. - let mut builder = client.issue_labels::<IssueLabel>().first(250); + // Fetch labels. When a team is provided, fetch both team-scoped and + // workspace-wide labels so that workspace labels can be used on any team's issues. + let mut all_labels: Vec<IssueLabel> = Vec::new(); + if let Some(tid) = team_id { + // Team-scoped labels first. let filter: lineark_sdk::generated::inputs::IssueLabelFilter = serde_json::from_value(serde_json::json!({ "team": { "id": { "eq": tid } } })) .expect("valid IssueLabelFilter"); - builder = builder.filter(filter); + let conn = client + .issue_labels::<IssueLabel>() + .first(250) + .filter(filter) + .send() + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + all_labels.extend(conn.nodes); + + // Workspace-wide labels (no team). + let ws_filter: lineark_sdk::generated::inputs::IssueLabelFilter = + serde_json::from_value(serde_json::json!({ "team": { "null": true } })) + .expect("valid IssueLabelFilter"); + let ws_conn = client + .issue_labels::<IssueLabel>() + .first(250) + .filter(ws_filter) + .send() + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + all_labels.extend(ws_conn.nodes); + } else { + let conn = client + .issue_labels::<IssueLabel>() + .first(250) + .send() + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + all_labels = conn.nodes; } - let conn = builder.send().await.map_err(|e| anyhow::anyhow!("{}", e))?; let mut resolved = Vec::with_capacity(names_or_ids.len()); for item in names_or_ids { if uuid::Uuid::parse_str(item).is_ok() { resolved.push(item.clone()); } else { - let found = conn.nodes.iter().find(|l| { + let found = all_labels.iter().find(|l| { l.name .as_deref() .is_some_and(|n| n.eq_ignore_ascii_case(item)) @@ -264,7 +294,7 @@ pub async fn resolve_label_ids( Some(label) => resolved.push(label.id.clone().unwrap_or_default()), None => { let available: Vec<String> = - conn.nodes.iter().filter_map(|l| l.name.clone()).collect(); + all_labels.iter().filter_map(|l| l.name.clone()).collect(); return Err(anyhow::anyhow!( "Label '{}' not found. Available: {}", item, diff --git a/crates/lineark/src/commands/issues.rs b/crates/lineark/src/commands/issues.rs index bcee59e..c1610ca 100644 --- a/crates/lineark/src/commands/issues.rs +++ b/crates/lineark/src/commands/issues.rs @@ -3,8 +3,8 @@ use lineark_sdk::generated::inputs::{ IssueCreateInput, IssueFilter, IssueUpdateInput, WorkflowStateFilter, }; use lineark_sdk::generated::types::{ - Comment, CommentConnection, Issue, IssueConnection, IssueRelation, IssueRelationConnection, - IssueSearchResult, User, WorkflowState, + Comment, CommentConnection, Issue, IssueConnection, IssueLabel, IssueLabelConnection, + IssueRelation, IssueRelationConnection, IssueSearchResult, User, WorkflowState, }; use lineark_sdk::{Client, GraphQLFields}; use serde::{Deserialize, Serialize}; @@ -84,7 +84,7 @@ pub enum IssuesAction { /// Assignee: user name, display name, UUID, or `me`. #[arg(long)] assignee: Option<String>, - /// Comma-separated label names or UUIDs. + /// Comma-separated label names. #[arg(long, value_delimiter = ',')] labels: Option<Vec<String>>, /// Priority: 0=none, 1=urgent, 2=high, 3=medium, 4=low. @@ -158,7 +158,7 @@ pub enum IssuesAction { /// Priority: 0=none, 1=urgent, 2=high, 3=medium, 4=low. #[arg(short = 'p', long, value_parser = clap::value_parser!(i64).range(0..=4))] priority: Option<i64>, - /// Comma-separated label names or UUIDs. Behavior depends on --label-by. + /// Comma-separated label names. Behavior depends on --label-by. #[arg(long, value_delimiter = ',')] labels: Option<Vec<String>>, /// How to apply --labels: "replacing" (default), "adding", or "removing". @@ -195,7 +195,7 @@ pub enum IssuesAction { /// Estimate points (valid values depend on the team's estimation scale). #[arg(short = 'e', long)] estimate: Option<i64>, - /// Comma-separated label names or UUIDs. Behavior depends on --label-by. + /// Comma-separated label names. Behavior depends on --label-by. #[arg(long, value_delimiter = ',')] labels: Option<Vec<String>>, /// How to apply --labels: "replacing" (default), "adding", or "removing". @@ -253,10 +253,24 @@ struct IssueRow { assignee: String, team: String, estimate: String, + labels: String, #[tabled(skip)] url: String, } +fn format_labels(labels: &Option<LabelConnection>) -> String { + labels + .as_ref() + .map(|lc| { + lc.nodes + .iter() + .filter_map(|l| l.name.clone()) + .collect::<Vec<_>>() + .join(", ") + }) + .unwrap_or_default() +} + impl From<&IssueSummary> for IssueRow { fn from(i: &IssueSummary) -> Self { Self { @@ -279,6 +293,7 @@ impl From<&IssueSummary> for IssueRow { .and_then(|t| t.key.clone()) .unwrap_or_default(), estimate: format_estimate(i.estimate), + labels: format_labels(&i.labels), url: i.url.clone().unwrap_or_default(), } } @@ -306,6 +321,7 @@ impl From<&SearchSummary> for IssueRow { .and_then(|t| t.key.clone()) .unwrap_or_default(), estimate: format_estimate(i.estimate), + labels: format_labels(&i.labels), url: i.url.clone().unwrap_or_default(), } } @@ -331,6 +347,8 @@ pub struct IssueSummary { pub assignee: Option<UserRef>, #[graphql(nested)] pub team: Option<TeamRef>, + #[graphql(nested)] + pub labels: Option<LabelConnection>, } /// Lean search result type for `issues search` with nested fields. @@ -351,6 +369,8 @@ pub struct SearchSummary { pub assignee: Option<UserRef>, #[graphql(nested)] pub team: Option<TeamRef>, + #[graphql(nested)] + pub labels: Option<LabelConnection>, } // ── IssueDetail — custom type for `issues read` with nested data ───────── @@ -379,6 +399,8 @@ pub struct IssueDetail { #[graphql(nested)] pub team: Option<TeamRef>, #[graphql(nested)] + pub labels: Option<LabelConnection>, + #[graphql(nested)] pub relations: Option<RelationConnection>, #[graphql(nested)] pub inverse_relations: Option<RelationConnection>, @@ -414,6 +436,34 @@ pub struct TeamRef { pub key: Option<String>, } +/// Wrapper around IssueLabelConnection that serializes as a flat list of names: +/// `["Bug", "Feature"]` instead of `{ "nodes": [{ "name": "Bug" }, ...] }`. +#[derive(Debug, Clone, Deserialize, Default, GraphQLFields)] +#[graphql(full_type = IssueLabelConnection)] +#[serde(rename_all = "camelCase", default)] +pub struct LabelConnection { + #[graphql(nested)] + pub nodes: Vec<LabelNameRef>, +} + +impl Serialize for LabelConnection { + fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { + let names: Vec<&str> = self + .nodes + .iter() + .filter_map(|l| l.name.as_deref()) + .collect(); + names.serialize(serializer) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, GraphQLFields)] +#[graphql(full_type = IssueLabel)] +#[serde(rename_all = "camelCase", default)] +pub struct LabelNameRef { + pub name: Option<String>, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, GraphQLFields)] #[graphql(full_type = IssueRelationConnection)] #[serde(rename_all = "camelCase", default)] diff --git a/crates/lineark/src/commands/labels.rs b/crates/lineark/src/commands/labels.rs index f72fdf9..38fd129 100644 --- a/crates/lineark/src/commands/labels.rs +++ b/crates/lineark/src/commands/labels.rs @@ -1,5 +1,7 @@ use clap::Args; -use lineark_sdk::generated::inputs::IssueLabelFilter; +use lineark_sdk::generated::inputs::{ + IssueLabelCreateInput, IssueLabelFilter, IssueLabelUpdateInput, +}; use lineark_sdk::generated::types::IssueLabel; use lineark_sdk::{Client, GraphQLFields}; use serde::{Deserialize, Serialize}; @@ -16,6 +18,7 @@ pub struct LabelsCmd { } #[derive(Debug, clap::Subcommand)] +#[allow(clippy::large_enum_variant)] pub enum LabelsAction { /// List all issue labels. Use --team to filter by team. List { @@ -23,9 +26,75 @@ pub enum LabelsAction { #[arg(long)] team: Option<String>, }, + /// Create a new issue label. + /// + /// Examples: + /// lineark labels create "Bug" --color "#eb5757" + /// lineark labels create "Feature" --team ENG --color "#4ea7fc" --description "Feature requests" + /// lineark labels create "Category" --make-label-group --color "#000000" + /// lineark labels create "Sub-label" --parent-label-group GROUP-UUID --color "#ffffff" + Create { + /// Label name. + name: String, + /// Team key, name, or UUID. Omit for a workspace-wide label. + #[arg(long)] + team: Option<String>, + /// Label color (hex string, e.g. "#eb5757"). + #[arg(long)] + color: Option<String>, + /// Label description. + #[arg(long)] + description: Option<String>, + /// Parent label group UUID (makes this a sub-label; parent must be a group). + #[arg(long)] + parent_label_group: Option<String>, + /// Create as a group label (required before other labels can use it as --parent). + #[arg(long, default_value = "false")] + make_label_group: bool, + }, + /// Update an existing issue label. + /// + /// Examples: + /// lineark labels update LABEL-UUID --name "Renamed" + /// lineark labels update LABEL-UUID --color "#00ff00" --description "Updated" + /// lineark labels update LABEL-UUID --make-label-group # promote to group + /// lineark labels update LABEL-UUID --clear-label-group # demote (must have no children) + Update { + /// Label UUID. + id: String, + /// New label name. + #[arg(long)] + name: Option<String>, + /// New label color (hex string). + #[arg(long)] + color: Option<String>, + /// New label description. + #[arg(long)] + description: Option<String>, + /// New parent label group UUID (parent must be a group). + #[arg(long)] + parent_label_group: Option<String>, + /// Remove the parent label group relationship. + #[arg(long, default_value = "false", conflicts_with = "parent_label_group")] + clear_parent_label_group: bool, + /// Promote this label to a group (required before other labels can use it as --parent). + #[arg(long, default_value = "false", conflicts_with = "clear_label_group")] + make_label_group: bool, + /// Demote this group back to a plain label (fails if it still has children). + #[arg(long, default_value = "false")] + clear_label_group: bool, + }, + /// Delete an issue label. + /// + /// Examples: + /// lineark labels delete LABEL-UUID + Delete { + /// Label UUID. + id: String, + }, } -/// Lean label type that includes the parent team. +/// Lean label type that includes team, parent, and group status. #[derive(Debug, Clone, Default, Serialize, Deserialize, GraphQLFields)] #[graphql(full_type = IssueLabel)] #[serde(rename_all = "camelCase", default)] @@ -33,8 +102,11 @@ struct LabelSummary { pub id: Option<String>, pub name: Option<String>, pub color: Option<String>, + pub is_group: Option<bool>, #[graphql(nested)] pub team: Option<LabelTeamRef>, + #[graphql(nested)] + pub parent: Option<Box<LabelParentRef>>, } #[derive(Debug, Clone, Default, Serialize, Deserialize, GraphQLFields)] @@ -44,12 +116,54 @@ struct LabelTeamRef { pub key: Option<String>, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = IssueLabel)] +#[serde(rename_all = "camelCase", default)] +struct LabelParentRef { + pub name: Option<String>, +} + #[derive(Debug, Serialize, Tabled)] pub struct LabelRow { pub id: String, pub name: String, pub color: String, + pub is_label_group: String, pub team: String, + pub parent_label: String, +} + +/// Lean result type for label mutations. +#[derive(Debug, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = IssueLabel)] +#[serde(rename_all = "camelCase", default)] +struct LabelRef { + pub id: Option<String>, + pub name: Option<String>, + pub color: Option<String>, +} + +fn label_to_row(l: &LabelSummary) -> LabelRow { + LabelRow { + id: l.id.clone().unwrap_or_default(), + name: l.name.clone().unwrap_or_default(), + color: l.color.clone().unwrap_or_default(), + is_label_group: if l.is_group.unwrap_or(false) { + "yes".to_string() + } else { + String::new() + }, + team: l + .team + .as_ref() + .and_then(|t| t.key.clone()) + .unwrap_or_default(), + parent_label: l + .parent + .as_ref() + .and_then(|p| p.name.clone()) + .unwrap_or_default(), + } } pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Result<()> { @@ -68,23 +182,163 @@ pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Res let conn = query.send().await.map_err(|e| anyhow::anyhow!("{}", e))?; - let rows: Vec<LabelRow> = conn - .nodes + // Sort: groups first (with their children right after), then ungrouped labels. + let labels = &conn.nodes; + let mut rows: Vec<LabelRow> = Vec::with_capacity(labels.len()); + + // Collect group labels and their children. + let mut used_ids: std::collections::HashSet<String> = std::collections::HashSet::new(); + for g in labels.iter().filter(|l| l.is_group.unwrap_or(false)) { + let gid = g.id.clone().unwrap_or_default(); + let gname = g.name.clone().unwrap_or_default(); + used_ids.insert(gid.clone()); + rows.push(label_to_row(g)); + // Children of this group, sorted by name. + let mut children: Vec<&LabelSummary> = labels + .iter() + .filter(|l| { + l.parent + .as_ref() + .and_then(|p| p.name.as_deref()) + .is_some_and(|n| n == gname) + }) + .collect(); + children.sort_by(|a, b| { + a.name + .as_deref() + .unwrap_or("") + .cmp(b.name.as_deref().unwrap_or("")) + }); + for c in children { + used_ids.insert(c.id.clone().unwrap_or_default()); + rows.push(label_to_row(c)); + } + } + + // Remaining ungrouped labels (no parent, not a group). + let mut rest: Vec<&LabelSummary> = labels .iter() - .map(|l| LabelRow { - id: l.id.clone().unwrap_or_default(), - name: l.name.clone().unwrap_or_default(), - color: l.color.clone().unwrap_or_default(), - team: l - .team - .as_ref() - .and_then(|t| t.key.clone()) - .unwrap_or_default(), - }) + .filter(|l| !used_ids.contains(l.id.as_deref().unwrap_or(""))) .collect(); + rest.sort_by(|a, b| { + a.name + .as_deref() + .unwrap_or("") + .cmp(b.name.as_deref().unwrap_or("")) + }); + for l in rest { + rows.push(label_to_row(l)); + } output::print_table(&rows, format); } + LabelsAction::Create { + name, + team, + color, + description, + parent_label_group, + make_label_group, + } => { + let team_id = match team { + Some(ref t) => Some(resolve_team_id(client, t).await?), + None => None, + }; + + let input = IssueLabelCreateInput { + name: Some(name), + color, + description, + parent_id: parent_label_group, + team_id, + is_group: if make_label_group { Some(true) } else { None }, + ..Default::default() + }; + + let label = client + .issue_label_create::<LabelRef>(None, input) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&label, format); + } + LabelsAction::Update { + id, + name, + color, + description, + parent_label_group, + clear_parent_label_group, + make_label_group, + clear_label_group, + } => { + if name.is_none() + && color.is_none() + && description.is_none() + && parent_label_group.is_none() + && !clear_parent_label_group + && !make_label_group + && !clear_label_group + { + return Err(anyhow::anyhow!( + "No update fields provided. Use --name, --color, --description, --parent-label-group, --clear-parent-label-group, --make-label-group, or --clear-label-group." + )); + } + + let is_group = if make_label_group { + Some(true) + } else if clear_label_group { + Some(false) + } else { + None + }; + + let input = IssueLabelUpdateInput { + name, + color, + description, + parent_id: parent_label_group, + is_group, + ..Default::default() + }; + + // When --clear-parent-label-group is used, send `parentId: null` to the API. + // The generated input uses skip_serializing_if so None omits the field. + let label = if clear_parent_label_group { + let mut input_val = serde_json::to_value(&input)?; + input_val + .as_object_mut() + .unwrap() + .insert("parentId".to_string(), serde_json::Value::Null); + let variables = serde_json::json!({ "input": input_val, "id": id }); + let sel = <LabelRef as GraphQLFields>::selection(); + let query = format!( + "mutation($input: IssueLabelUpdateInput!, $id: String!) {{ issueLabelUpdate(input: $input, id: $id) {{ success issueLabel {{ {sel} }} }} }}" + ); + let payload: serde_json::Value = client + .execute(&query, variables, "issueLabelUpdate") + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + serde_json::from_value::<LabelRef>( + payload.get("issueLabel").cloned().unwrap_or_default(), + )? + } else { + client + .issue_label_update::<LabelRef>(None, input, id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))? + }; + + output::print_one(&label, format); + } + LabelsAction::Delete { id } => { + let result = client + .issue_label_delete(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 61cf2e0..a6e0cad 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -48,7 +48,19 @@ COMMANDS: [--start-date DATE] [--target-date DATE] Dates (YYYY-MM-DD) [-p 0-4] [--content TEXT] Priority, markdown content [--icon ICON] [--color COLOR] Icon, color - lineark labels list [--team KEY] List issue labels (includes team key) + lineark labels list [--team KEY] List labels (group, team, parent, color) + lineark labels create <NAME> Create a label (workspace-wide if no --team) + [--team KEY] [--color HEX] Team, color + [--description TEXT] Description + [--parent-label-group ID] Nest under a group label + [--make-label-group] Create as a group label + lineark labels update <ID> Update a label + [--name TEXT] [--color HEX] Name, color + [--description TEXT] Description + [--parent-label-group ID] Nest under a group label + [--clear-parent-label-group] Remove parent group + [--make-label-group] [--clear-label-group] Promote/demote group + lineark labels delete <ID> Delete a label lineark cycles list [-l N] [--team KEY] List cycles [--active] Only the active cycle [--around-active N] Active ± N neighbors diff --git a/crates/lineark/tests/offline.rs b/crates/lineark/tests/offline.rs index 1a6b256..68fe480 100644 --- a/crates/lineark/tests/offline.rs +++ b/crates/lineark/tests/offline.rs @@ -324,6 +324,72 @@ fn labels_list_help_shows_team_flag() { .stdout(predicate::str::contains("--team")); } +#[test] +fn labels_help_shows_subcommands() { + lineark() + .args(["labels", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("list")) + .stdout(predicate::str::contains("create")) + .stdout(predicate::str::contains("update")) + .stdout(predicate::str::contains("delete")); +} + +#[test] +fn labels_create_help_shows_flags() { + lineark() + .args(["labels", "create", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--color")) + .stdout(predicate::str::contains("--team")) + .stdout(predicate::str::contains("--description")) + .stdout(predicate::str::contains("--parent")); +} + +#[test] +fn labels_update_help_shows_flags() { + lineark() + .args(["labels", "update", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--name")) + .stdout(predicate::str::contains("--color")) + .stdout(predicate::str::contains("--description")) + .stdout(predicate::str::contains("--parent")); +} + +#[test] +fn labels_update_no_flags_prints_error() { + lineark() + .args(["--api-token", "fake-token", "labels", "update", "some-uuid"]) + .assert() + .failure() + .stderr(predicate::str::contains("No update fields provided")); +} + +#[test] +fn labels_delete_help_shows_description() { + lineark() + .args(["labels", "delete", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("<ID>")); +} + +#[test] +fn usage_includes_labels_crud() { + lineark() + .arg("usage") + .assert() + .success() + .stdout(predicate::str::contains("labels list")) + .stdout(predicate::str::contains("labels create")) + .stdout(predicate::str::contains("labels update")) + .stdout(predicate::str::contains("labels delete")); +} + // ── Auth error handling ───────────────────────────────────────────────────── #[test] diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index 771032b..03420ea 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -51,7 +51,8 @@ fn delete_issue(issue_id: &str) { .block_on(async { client.issue_delete::<Issue>(Some(true), id).await.unwrap() }); } -/// Retry a closure up to `max_attempts` times with backoff. +/// Retry a closure up to `max_attempts` times with exponential backoff. +/// Delays: 0s, 1s, 2s, 4s, 8s, 10s, 10s, ... (capped at 10s). /// Returns `Ok(T)` on the first successful attempt, or `Err(last_error_message)`. fn retry_with_backoff<T, F>(max_attempts: u32, mut f: F) -> Result<T, String> where @@ -61,10 +62,8 @@ where for attempt in 0..max_attempts { let delay = if attempt == 0 { 0 - } else if attempt < 3 { - 1 } else { - 3 + std::cmp::min(1u64 << (attempt - 1), 10) }; if delay > 0 { std::thread::sleep(std::time::Duration::from_secs(delay)); @@ -77,6 +76,39 @@ where Err(last_err) } +/// Wait for the Linear API to propagate recently created resources. +/// Linear is eventually consistent — created resources may not be queryable immediately. +fn settle() { + std::thread::sleep(std::time::Duration::from_secs(2)); +} + +/// Run a lineark CLI command with retry logic. +/// Retries up to 3 times with backoff for transient API errors (e.g., "conflict on insert"). +fn run_lineark_with_retry(args: &[&str]) -> std::process::Output { + for attempt in 0..3u32 { + if attempt > 0 { + std::thread::sleep(std::time::Duration::from_secs(1u64 << attempt)); + } + let output = lineark() + .args(args) + .output() + .expect("failed to execute lineark"); + if output.status.success() { + return output; + } + let stderr = String::from_utf8_lossy(&output.stderr); + // Only retry on transient "conflict on insert" errors from the Linear API. + if !stderr.contains("conflict on insert") { + return output; + } + } + // Final attempt without retry. + lineark() + .args(args) + .output() + .expect("failed to execute lineark") +} + /// RAII guard — permanently deletes a team on drop. /// Ensures cleanup even when the test panics mid-way. struct TeamGuard { @@ -132,6 +164,24 @@ impl Drop for ProjectGuard { } } +/// RAII guard — deletes an issue label on drop. +struct LabelGuard { + token: String, + id: String, +} + +impl Drop for LabelGuard { + 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.issue_label_delete(id).await }); + } +} + /// Helper: create a fresh test team via the SDK and return (key, id, guard). fn create_test_team() -> (String, String, TeamGuard) { use lineark_sdk::generated::inputs::TeamCreateInput; @@ -152,6 +202,8 @@ fn create_test_team() -> (String, String, TeamGuard) { token, id: team_id.clone(), }; + // Wait for the team to propagate through Linear's eventually-consistent backend. + settle(); (team_key, team_id, guard) } @@ -268,6 +320,360 @@ mod online { } } + #[test_with::runtime_ignore_if(no_online_test_token)] + fn labels_create_update_and_delete() { + let token = api_token(); + + // Create a workspace-level label. + let unique_name = format!("[test] lbl-crud {}", &uuid::Uuid::new_v4().to_string()[..8]); + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "labels", + "create", + &unique_name, + "--color", + "#eb5757", + ]) + .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(), + "labels create should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let created: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let label_id = created["id"] + .as_str() + .expect("created label should have id") + .to_string(); + let _label_guard = LabelGuard { + token: token.clone(), + id: label_id.clone(), + }; + assert_eq!(created["name"].as_str(), Some(unique_name.as_str())); + assert_eq!(created["color"].as_str(), Some("#eb5757")); + + // Update the label color. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "labels", + "update", + &label_id, + "--color", + "#4ea7fc", + ]) + .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(), + "labels update should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let updated: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(updated["color"].as_str(), Some("#4ea7fc")); + + // Delete the label via CLI. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "labels", + "delete", + &label_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(), + "labels delete should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn labels_group_lifecycle() { + let token = api_token(); + let uid = &uuid::Uuid::new_v4().to_string()[..8]; + + // 1. Create a group label with --group. + let group_name = format!("[test] Group {uid}"); + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "labels", + "create", + &group_name, + "--color", + "#000000", + "--make-label-group", + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "group create failed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let group: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let group_id = group["id"].as_str().unwrap().to_string(); + let _group_guard = LabelGuard { + token: token.clone(), + id: group_id.clone(), + }; + + // 2. Create a child label under the group. + let child_name = format!("[test] Child {uid}"); + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "labels", + "create", + &child_name, + "--color", + "#ffffff", + "--parent-label-group", + &group_id, + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "child create failed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let child: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let child_id = child["id"].as_str().unwrap().to_string(); + let _child_guard = LabelGuard { + token: token.clone(), + id: child_id.clone(), + }; + + // 3. List labels — child should show parent name, group should show "yes". + let output = lineark() + .args(["--api-token", &token, "--format", "json", "labels", "list"]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "labels list failed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let labels: Vec<serde_json::Value> = serde_json::from_str(&stdout).unwrap(); + + let group_row = labels + .iter() + .find(|l| l["name"].as_str() == Some(group_name.as_str())) + .expect("group should appear in list"); + assert_eq!( + group_row["is_label_group"].as_str(), + Some("yes"), + "group label should show 'yes' in is_label_group column" + ); + + let child_row = labels + .iter() + .find(|l| l["name"].as_str() == Some(child_name.as_str())) + .expect("child should appear in list"); + assert_eq!( + child_row["parent_label"].as_str(), + Some(group_name.as_str()), + "child should show parent_label name in list" + ); + + // 4. Clear the child's parent with --clear-parent. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "labels", + "update", + &child_id, + "--clear-parent-label-group", + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "clear-parent failed.\nstdout: {stdout}\nstderr: {stderr}" + ); + + // 5. Demote the group back to a plain label with --no-group. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "labels", + "update", + &group_id, + "--clear-label-group", + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "demote group failed.\nstdout: {stdout}\nstderr: {stderr}" + ); + + // 6. List again — group column should be empty, parent should be empty. + let output = lineark() + .args(["--api-token", &token, "--format", "json", "labels", "list"]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "labels list failed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let labels: Vec<serde_json::Value> = serde_json::from_str(&stdout).unwrap(); + + let group_row = labels + .iter() + .find(|l| l["name"].as_str() == Some(group_name.as_str())) + .expect("group should still appear in list"); + assert_eq!( + group_row["is_label_group"].as_str(), + Some(""), + "is_label_group should be empty after --no-group" + ); + + let child_row = labels + .iter() + .find(|l| l["name"].as_str() == Some(child_name.as_str())) + .expect("child should appear in list"); + assert_eq!( + child_row["parent_label"].as_str(), + Some(""), + "parent_label should be empty after --clear-parent" + ); + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn issues_create_with_spaced_label_and_read_back() { + let token = api_token(); + let (_team_key, team_id, _team_guard) = create_test_team(); + + // Create a label with a space in the name. + let uid = &uuid::Uuid::new_v4().to_string()[..8]; + let label_name = format!("[test] Tech Debt {uid}"); + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "labels", + "create", + &label_name, + "--color", + "#eb5757", + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "labels create should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let created: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let label_id = created["id"].as_str().unwrap().to_string(); + let _label_guard = LabelGuard { + token: token.clone(), + id: label_id, + }; + + // Create an issue with the spaced label. + let issue_title = format!("[test] spaced label {uid}"); + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "issues", + "create", + &issue_title, + "--team", + &team_id, + "--labels", + &label_name, + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "issues create with spaced label should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let issue: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let issue_id = issue["id"].as_str().unwrap().to_string(); + let _issue_guard = IssueGuard { + token: token.clone(), + id: issue_id.clone(), + }; + + // Read the issue back and verify label appears by name. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "issues", + "read", + &issue_id, + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "issues read should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let detail: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let labels = detail["labels"] + .as_array() + .expect("labels should be a flat array of names"); + let label_names: Vec<&str> = labels.iter().filter_map(|l| l.as_str()).collect(); + assert!( + label_names.contains(&label_name.as_str()), + "issue should have the spaced label '{label_name}', got: {label_names:?}" + ); + } + // ── Issues ──────────────────────────────────────────────────────────────── #[test_with::runtime_ignore_if(no_online_test_token)] @@ -730,13 +1136,16 @@ mod online { .unwrap(); assert!(output.status.success(), "archive should succeed"); + // Wait for the archive to propagate before searching. + settle(); + // Unarchive using the HUMAN identifier (e.g. CAD-1234), not the UUID. // This is the regression case: search_issues must include_archived(true) // for resolve_issue_id to find archived issues. // // Linear's search index is async — the newly created+archived issue may - // not be searchable immediately. Retry with backoff to avoid flakiness. - let stdout = retry_with_backoff(8, || { + // not be searchable immediately. Retry with generous backoff to avoid flakiness. + let stdout = retry_with_backoff(10, || { let output = lineark() .args([ "--api-token", @@ -2026,25 +2435,22 @@ mod online { let (team_key, _team_id, _team_guard) = create_test_team(); - // Create a project via CLI. - let output = lineark() - .args([ - "--api-token", - &token, - "--format", - "json", - "projects", - "create", - &unique_name, - "--team", - &team_key, - "--description", - "Automated CLI test project — will be deleted.", - "--priority", - "3", - ]) - .output() - .expect("failed to execute lineark"); + // Create a project via CLI (with retry for transient "conflict on insert" errors). + let output = run_lineark_with_retry(&[ + "--api-token", + &token, + "--format", + "json", + "projects", + "create", + &unique_name, + "--team", + &team_key, + "--description", + "Automated CLI test project — will be deleted.", + "--priority", + "3", + ]); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( @@ -2179,25 +2585,22 @@ mod online { let (team_key, _team_id, _team_guard) = create_test_team(); - // Create a project with --lead me. - let output = lineark() - .args([ - "--api-token", - &token, - "--format", - "json", - "projects", - "create", - &unique_name, - "--team", - &team_key, - "--lead", - "me", - "--description", - "Test project for read command.", - ]) - .output() - .expect("failed to execute lineark"); + // Create a project with --lead me (with retry for transient API errors). + let output = run_lineark_with_retry(&[ + "--api-token", + &token, + "--format", + "json", + "projects", + "create", + &unique_name, + "--team", + &team_key, + "--lead", + "me", + "--description", + "Test project for read command.", + ]); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( @@ -2299,25 +2702,22 @@ mod online { let (team_key, _team_id, _team_guard) = create_test_team(); - // Create a project with --lead me --members me. - let output = lineark() - .args([ - "--api-token", - &token, - "--format", - "json", - "projects", - "create", - &unique_name, - "--team", - &team_key, - "--lead", - "me", - "--members", - "me", - ]) - .output() - .expect("failed to execute lineark"); + // Create a project with --lead me --members me (with retry for transient API errors). + let output = run_lineark_with_retry(&[ + "--api-token", + &token, + "--format", + "json", + "projects", + "create", + &unique_name, + "--team", + &team_key, + "--lead", + "me", + "--members", + "me", + ]); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( @@ -3695,20 +4095,17 @@ mod online { "[test] CLI project filter {}", &uuid::Uuid::new_v4().to_string()[..8] ); - let output = lineark() - .args([ - "--api-token", - &token, - "--format", - "json", - "projects", - "create", - &project_label, - "--team", - &team_key, - ]) - .output() - .expect("failed to execute lineark"); + let output = run_lineark_with_retry(&[ + "--api-token", + &token, + "--format", + "json", + "projects", + "create", + &project_label, + "--team", + &team_key, + ]); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( diff --git a/schema/operations.toml b/schema/operations.toml index d37f8e4..95fe199 100644 --- a/schema/operations.toml +++ b/schema/operations.toml @@ -58,5 +58,8 @@ projectDelete = true teamCreate = true teamUpdate = true teamDelete = true +issueLabelCreate = true +issueLabelUpdate = true +issueLabelDelete = true teamMembershipCreate = true teamMembershipDelete = true