diff --git a/crates/lineark/README.md b/crates/lineark/README.md index ea39b79..997a65b 100644 --- a/crates/lineark/README.md +++ b/crates/lineark/README.md @@ -72,7 +72,7 @@ lineark projects create --team KEY Create a project [--description TEXT] [--lead NAME-OR-ID|me] Description, lead, dates [--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 + [-p PRIORITY] [--content TEXT] ... See --help for all options lineark labels list [--team KEY] List labels (group, team, parent) lineark labels create Create a label [--team KEY] [--color HEX] Team, color @@ -96,14 +96,14 @@ 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] [-e N] [--assignee NAME-OR-ID|me] Priority, estimate, assignee + [-p PRIORITY] [-e N] [--assignee NAME-OR-ID|me] Priority (0-4 or name), estimate [--labels NAME,...] [-s NAME] ... Labels, status — see --help lineark issues update <IDENTIFIER> Update an issue - [-s NAME] [-p 0-4] [-e N] Status, priority, estimate + [-s NAME] [-p PRIORITY] [-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 + [-s NAME] [-p PRIORITY] [--assignee ...] 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 diff --git a/crates/lineark/src/commands/helpers.rs b/crates/lineark/src/commands/helpers.rs index 657cd61..8a14a9a 100644 --- a/crates/lineark/src/commands/helpers.rs +++ b/crates/lineark/src/commands/helpers.rs @@ -409,3 +409,40 @@ pub async fn resolve_cycle_id( available.join(", ") )) } + +/// Parse a priority value from either a number (0-4) or a name. +/// +/// Mapping defined by the Linear GraphQL schema on `IssueCreateInput.priority` and +/// `IssueUpdateInput.priority` (see `schema/schema.graphql`): +/// +/// | Value | Schema label | `priorityLabel` from API | +/// |-------|-------------|--------------------------| +/// | 0 | No priority | No priority | +/// | 1 | Urgent | Urgent | +/// | 2 | High | High | +/// | 3 | Normal | Medium | +/// | 4 | Low | Low | +/// +/// Note: the schema says "Normal" but the API's `priorityLabel` field returns "Medium" +/// for priority 3. We accept both `"medium"` and `"normal"` as input. +pub fn parse_priority(s: &str) -> Result<i64, String> { + let s = s.trim(); + if let Ok(n) = s.parse::<i64>() { + if (0..=4).contains(&n) { + return Ok(n); + } + return Err(format!( + "invalid priority '{n}': valid values are 0-4 or none, urgent, high, medium, low" + )); + } + match s.to_ascii_lowercase().as_str() { + "none" => Ok(0), + "urgent" => Ok(1), + "high" => Ok(2), + "medium" | "normal" => Ok(3), + "low" => Ok(4), + _ => Err(format!( + "invalid priority '{s}': valid values are 0-4 or none, urgent, high, medium, low" + )), + } +} diff --git a/crates/lineark/src/commands/issues.rs b/crates/lineark/src/commands/issues.rs index c1610ca..1d28285 100644 --- a/crates/lineark/src/commands/issues.rs +++ b/crates/lineark/src/commands/issues.rs @@ -11,8 +11,8 @@ use serde::{Deserialize, Serialize}; use tabled::Tabled; use super::helpers::{ - resolve_cycle_id, resolve_issue_id, resolve_label_ids, resolve_project_id, resolve_team_id, - resolve_user_id_or_me, + parse_priority, resolve_cycle_id, resolve_issue_id, resolve_label_ids, resolve_project_id, + resolve_team_id, resolve_user_id_or_me, }; use crate::output::{self, Format}; @@ -72,8 +72,8 @@ pub enum IssuesAction { /// /// Examples: /// lineark issues create "Fix the bug" --team ENG - /// lineark issues create "Add feature" --team ENG --priority 2 --description "Details here" - /// lineark issues create "Urgent fix" --team ENG --priority 1 --labels Bug,Frontend + /// lineark issues create "Add feature" --team ENG --priority high --description "Details here" + /// lineark issues create "Urgent fix" --team ENG -p urgent --labels Bug,Frontend /// lineark issues create "My task" --team ENG --assignee me Create { /// Issue title. @@ -87,8 +87,8 @@ pub enum IssuesAction { /// Comma-separated label names. #[arg(long, value_delimiter = ',')] labels: Option<Vec<String>>, - /// 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: 0-4 or none, urgent, high, medium, low. + #[arg(short = 'p', long, value_parser = parse_priority)] priority: Option<i64>, /// Estimate points (valid values depend on the team's estimation scale). #[arg(short = 'e', long)] @@ -145,7 +145,7 @@ pub enum IssuesAction { /// Batch update multiple issues at once. Returns the updated issues. /// /// Examples: - /// lineark issues batch-update ENG-1 ENG-2 --priority 2 + /// lineark issues batch-update ENG-1 ENG-2 --priority high /// lineark issues batch-update ENG-1 ENG-2 ENG-3 --status "In Progress" /// lineark issues batch-update ENG-1 ENG-2 --assignee me --labels Bug --label-by adding BatchUpdate { @@ -155,8 +155,8 @@ pub enum IssuesAction { /// New status name (resolved against the first issue's team workflow states). #[arg(short = 's', long)] status: Option<String>, - /// 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: 0-4 or none, urgent, high, medium, low. + #[arg(short = 'p', long, value_parser = parse_priority)] priority: Option<i64>, /// Comma-separated label names. Behavior depends on --label-by. #[arg(long, value_delimiter = ',')] @@ -181,7 +181,7 @@ pub enum IssuesAction { /// /// Examples: /// lineark issues update ENG-123 --status "In Progress" - /// lineark issues update ENG-123 --priority 1 --assignee "John Doe" + /// lineark issues update ENG-123 --priority urgent --assignee "John Doe" /// lineark issues update ENG-123 --labels Bug,Frontend --label-by adding Update { /// Issue identifier (e.g., ENG-123) or UUID. @@ -189,8 +189,8 @@ pub enum IssuesAction { /// New status name (resolved against the issue's team workflow states). #[arg(short = 's', long)] status: Option<String>, - /// 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: 0-4 or none, urgent, high, medium, low. + #[arg(short = 'p', long, value_parser = parse_priority)] priority: Option<i64>, /// Estimate points (valid values depend on the team's estimation scale). #[arg(short = 'e', long)] @@ -245,7 +245,7 @@ pub enum LabelMode { struct IssueRow { identifier: String, title: String, - #[serde(rename = "priorityLabel")] + #[serde(rename = "priority")] #[tabled(rename = "priority")] priority_label: String, #[tabled(rename = "status")] @@ -337,7 +337,7 @@ pub struct IssueSummary { pub id: Option<String>, pub identifier: Option<String>, pub title: Option<String>, - pub priority: Option<f64>, + #[serde(rename(serialize = "priority"))] pub priority_label: Option<String>, pub estimate: Option<f64>, pub url: Option<String>, @@ -359,7 +359,7 @@ pub struct SearchSummary { pub id: Option<String>, pub identifier: Option<String>, pub title: Option<String>, - pub priority: Option<f64>, + #[serde(rename(serialize = "priority"))] pub priority_label: Option<String>, pub estimate: Option<f64>, pub url: Option<String>, @@ -385,7 +385,7 @@ pub struct IssueDetail { pub identifier: Option<String>, pub title: Option<String>, pub description: Option<String>, - pub priority: Option<f64>, + #[serde(rename(serialize = "priority"))] pub priority_label: Option<String>, pub estimate: Option<f64>, pub url: Option<String>, @@ -1129,6 +1129,44 @@ async fn resolve_state_id( mod tests { use super::*; + #[test] + fn parse_priority_numeric_values() { + assert_eq!(parse_priority("0").unwrap(), 0); + assert_eq!(parse_priority("1").unwrap(), 1); + assert_eq!(parse_priority("4").unwrap(), 4); + } + + #[test] + fn parse_priority_textual_values() { + assert_eq!(parse_priority("none").unwrap(), 0); + assert_eq!(parse_priority("urgent").unwrap(), 1); + assert_eq!(parse_priority("high").unwrap(), 2); + assert_eq!(parse_priority("medium").unwrap(), 3); + assert_eq!(parse_priority("normal").unwrap(), 3); + assert_eq!(parse_priority("low").unwrap(), 4); + } + + #[test] + fn parse_priority_case_insensitive() { + assert_eq!(parse_priority("URGENT").unwrap(), 1); + assert_eq!(parse_priority("High").unwrap(), 2); + assert_eq!(parse_priority("LOW").unwrap(), 4); + } + + #[test] + fn parse_priority_trims_whitespace() { + assert_eq!(parse_priority(" urgent ").unwrap(), 1); + assert_eq!(parse_priority(" 2 ").unwrap(), 2); + assert!(parse_priority(" ").is_err()); + } + + #[test] + fn parse_priority_rejects_invalid() { + assert!(parse_priority("bogus").is_err()); + assert!(parse_priority("5").is_err()); + assert!(parse_priority("-1").is_err()); + } + #[test] fn format_estimate_none_returns_empty() { assert_eq!(format_estimate(None), ""); diff --git a/crates/lineark/src/commands/projects.rs b/crates/lineark/src/commands/projects.rs index d83f4eb..7a20336 100644 --- a/crates/lineark/src/commands/projects.rs +++ b/crates/lineark/src/commands/projects.rs @@ -8,7 +8,8 @@ use serde::{Deserialize, Serialize}; use tabled::Tabled; use super::helpers::{ - resolve_project_id, resolve_team_ids, resolve_user_id_or_me, resolve_user_ids_or_me, + parse_priority, resolve_project_id, resolve_team_ids, resolve_user_id_or_me, + resolve_user_ids_or_me, }; use crate::output::{self, Format}; @@ -68,8 +69,8 @@ pub enum ProjectsAction { /// Planned target/completion date (YYYY-MM-DD). #[arg(long)] target_date: Option<String>, - /// 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: 0-4 or none, urgent, high, medium, low. + #[arg(short = 'p', long, value_parser = parse_priority)] priority: Option<i64>, /// Markdown content for the project. #[arg(long)] diff --git a/crates/lineark/src/commands/usage.rs b/crates/lineark/src/commands/usage.rs index a013c3a..865d50b 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -87,12 +87,12 @@ COMMANDS: [--team KEY] [--assignee NAME-OR-ID|me] Filter by team, assignee, status [--status NAME,...] [--show-done] Comma-separated status names lineark issues create <TITLE> --team KEY Create an issue - [-p 0-4] [-e N] [--assignee NAME-OR-ID|me] 0=none 1=urgent 2=high 3=medium 4=low + [-p PRIORITY] [-e N] [--assignee NAME-OR-ID|me] 0-4 or none/urgent/high/medium/low [--labels NAME,...] [-d TEXT] [-s NAME] Label names, status name [--parent ID] [--project NAME-OR-ID] Parent issue, project, cycle [--cycle NAME-OR-ID] lineark issues update <IDENTIFIER> Update an issue - [-s NAME] [-p 0-4] [-e N] Status, priority, estimate + [-s NAME] [-p PRIORITY] [-e N] Status, priority, estimate [--assignee NAME-OR-ID|me] Assignee [--labels NAME,...] [--label-by adding|replacing|removing] [--clear-labels] [-t TEXT] [-d TEXT] Title, description diff --git a/crates/lineark/tests/offline.rs b/crates/lineark/tests/offline.rs index bb987a9..ef3f982 100644 --- a/crates/lineark/tests/offline.rs +++ b/crates/lineark/tests/offline.rs @@ -123,6 +123,32 @@ fn issues_create_help_shows_flags() { .stdout(predicate::str::contains("--labels")); } +#[test] +fn issues_create_rejects_invalid_priority_name() { + lineark() + .args([ + "issues", + "create", + "test", + "--team", + "FAKE", + "--priority", + "bogus", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid priority")); +} + +#[test] +fn issues_create_accepts_textual_priority_in_help() { + lineark() + .args(["issues", "create", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("none, urgent, high, medium, low")); +} + #[test] fn issues_update_help_shows_flags() { lineark() diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index eebed47..52f4000 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -591,21 +591,22 @@ mod online { "assignee should be a flat string, got: {}", issue["assignee"] ); - // id and priority (numeric) must be absent + // id must be absent assert!( issue.get("id").is_none(), "id (UUID) should not be in list output" ); + // priority should be a text label, not numeric; priorityLabel should be gone assert!( - issue.get("priority").is_none(), - "priority (numeric) should not be in list output" + issue.get("priority").is_some_and(|v| v.is_string()), + "priority should be a text label in list output" ); - // url and priorityLabel must be present - assert!(issue.get("url").is_some(), "url should be in list output"); assert!( - issue.get("priorityLabel").is_some(), - "priorityLabel should be in list output" + issue.get("priorityLabel").is_none(), + "priorityLabel should not be in list output (renamed to priority)" ); + // url must be present + assert!(issue.get("url").is_some(), "url should be in list output"); } } @@ -685,8 +686,12 @@ mod online { "id (UUID) should not be in search output" ); assert!( - issue.get("priority").is_none(), - "priority (numeric) should not be in search output" + issue.get("priority").is_some_and(|v| v.is_string()), + "priority should be a text label in search output" + ); + assert!( + issue.get("priorityLabel").is_none(), + "priorityLabel should not be in search output (renamed to priority)" ); } } @@ -774,6 +779,153 @@ mod online { delete_issue(issue_id); } + // ── Issues create/update with textual priority names ─────────────────────── + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn issues_create_with_textual_priority_and_update() { + let token = test_token(); + let unique_name = format!( + "[test] CLI textual priority {}", + &uuid::Uuid::new_v4().to_string()[..8] + ); + + let team = create_test_team(); + let team_key = team.key.clone(); + + // Create with --priority urgent (textual). + let output = run_lineark_with_retry(&[ + "--api-token", + &token, + "--format", + "json", + "issues", + "create", + &unique_name, + "--team", + &team_key, + "--priority", + "urgent", + ]); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "create with --priority urgent should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let created: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let issue_id = created["id"] + .as_str() + .expect("created issue should have id"); + let _issue_guard = IssueGuard { + token: token.clone(), + id: issue_id.to_string(), + }; + + // Read back and verify "priority": "Urgent". + let read_output = retry_with_backoff(5, || { + let out = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "issues", + "read", + issue_id, + ]) + .output() + .expect("failed to execute lineark"); + if !out.status.success() { + return Err(format!( + "issues read failed: {}", + String::from_utf8_lossy(&out.stderr) + )); + } + let json: serde_json::Value = + serde_json::from_slice(&out.stdout).map_err(|e| e.to_string())?; + let prio = json.get("priority").and_then(|v| v.as_str()).unwrap_or(""); + if prio == "Urgent" { + Ok(out) + } else { + Err(format!("priority is '{prio}', expected 'Urgent'")) + } + }) + .expect("reading issue with Urgent priority should succeed"); + let read_json: serde_json::Value = serde_json::from_slice(&read_output.stdout).unwrap(); + assert_eq!( + read_json.get("priority").and_then(|v| v.as_str()), + Some("Urgent"), + "read should show priority as 'Urgent'" + ); + // priorityLabel should not be in output. + assert!( + read_json.get("priorityLabel").is_none(), + "priorityLabel should not appear in read output" + ); + + // Update with --priority low (textual). + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "issues", + "update", + issue_id, + "--priority", + "low", + ]) + .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(), + "update with --priority low should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + + // Read back and verify "priority": "Low". + let read_output = retry_with_backoff(5, || { + let out = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "issues", + "read", + issue_id, + ]) + .output() + .expect("failed to execute lineark"); + if !out.status.success() { + return Err(format!( + "issues read failed: {}", + String::from_utf8_lossy(&out.stderr) + )); + } + let json: serde_json::Value = + serde_json::from_slice(&out.stdout).map_err(|e| e.to_string())?; + let prio = json.get("priority").and_then(|v| v.as_str()).unwrap_or(""); + if prio == "Low" { + Ok(out) + } else { + Err(format!("priority is '{prio}', expected 'Low'")) + } + }) + .expect("reading issue with Low priority should succeed"); + let read_json: serde_json::Value = serde_json::from_slice(&read_output.stdout).unwrap(); + assert_eq!( + read_json.get("priority").and_then(|v| v.as_str()), + Some("Low"), + "read should show priority as 'Low' after update" + ); + + // Clean up. + delete_issue(issue_id); + } + // ── Issues archive / unarchive ───────────────────────────────────────────── #[test_with::runtime_ignore_if(no_online_test_token)]