diff --git a/CLAUDE.md b/CLAUDE.md index 2ccec04..7fc1baf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,6 +97,8 @@ When opening a PR, include a summary of changes and a test plan. If codegen was Before merging, run `/update-docs` to review and update all documentation (top-level README, CLI README, SDK README) so they reflect the current codebase. Documentation must stay in sync with code. +**Working on contributor fork branches:** When a contributor grants push access to their fork, use `git merge origin/main` (not rebase) to bring their branch up to date. Rebasing rewrites their commit history and requires force-pushing to their branch, which is inconsiderate if they're working concurrently. + ## Commit style - Use conventional commits (`feat:`, `fix:`, `chore:`, `docs:`, etc.) diff --git a/crates/lineark/src/commands/issues.rs b/crates/lineark/src/commands/issues.rs index 966a083..a3895f5 100644 --- a/crates/lineark/src/commands/issues.rs +++ b/crates/lineark/src/commands/issues.rs @@ -212,6 +212,7 @@ struct IssueRow { state: String, assignee: String, team: String, + estimate: String, #[tabled(skip)] url: String, } @@ -237,6 +238,7 @@ impl From<&IssueSummary> for IssueRow { .as_ref() .and_then(|t| t.key.clone()) .unwrap_or_default(), + estimate: format_estimate(i.estimate), url: i.url.clone().unwrap_or_default(), } } @@ -263,6 +265,7 @@ impl From<&SearchSummary> for IssueRow { .as_ref() .and_then(|t| t.key.clone()) .unwrap_or_default(), + estimate: format_estimate(i.estimate), url: i.url.clone().unwrap_or_default(), } } @@ -280,6 +283,7 @@ pub struct IssueSummary { pub title: Option, pub priority: Option, pub priority_label: Option, + pub estimate: Option, pub url: Option, #[graphql(nested)] pub state: Option, @@ -299,6 +303,7 @@ pub struct SearchSummary { pub title: Option, pub priority: Option, pub priority_label: Option, + pub estimate: Option, pub url: Option, #[graphql(nested)] pub state: Option, @@ -803,6 +808,14 @@ pub async fn run(cmd: IssuesCmd, client: &Client, format: Format) -> anyhow::Res // TODO(phase2): query workflowStates types instead of hardcoding state names const DONE_STATES: &[&str] = &["Done", "Canceled", "Cancelled", "Duplicate"]; +fn format_estimate(estimate: Option) -> String { + match estimate { + Some(v) if v.fract() == 0.0 => format!("{}", v as i64), + Some(v) => format!("{v}"), + None => String::new(), + } +} + fn print_issue_list(items: &[&IssueSummary], format: Format) { let rows: Vec = items.iter().map(|i| IssueRow::from(*i)).collect(); output::print_table(&rows, format); @@ -902,3 +915,25 @@ async fn resolve_state_id( available.join(", ") )) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_estimate_none_returns_empty() { + assert_eq!(format_estimate(None), ""); + } + + #[test] + fn format_estimate_whole_number_omits_decimal() { + assert_eq!(format_estimate(Some(3.0)), "3"); + assert_eq!(format_estimate(Some(0.0)), "0"); + } + + #[test] + fn format_estimate_fractional_preserves_decimal() { + assert_eq!(format_estimate(Some(1.5)), "1.5"); + assert_eq!(format_estimate(Some(0.5)), "0.5"); + } +} diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index 3f38d75..de91d90 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -3674,4 +3674,145 @@ mod online { "issues update --estimate should succeed.\nstdout: {stdout}\nstderr: {stderr}" ); } + + // ── Issues list includes estimate field ───────────────────────────────── + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn issues_list_json_includes_estimate_field() { + let token = api_token(); + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "issues", + "list", + "--limit", + "1", + ]) + .output() + .expect("failed to execute lineark"); + assert!(output.status.success(), "issues list should succeed"); + let json: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("output should be valid JSON"); + let arr = json.as_array().expect("should be an array"); + if let Some(issue) = arr.first() { + assert!( + issue.get("estimate").is_some(), + "each issue in list output should have an 'estimate' field" + ); + } + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn issues_search_json_includes_estimate_field() { + let token = api_token(); + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "issues", + "search", + "test", + "--limit", + "1", + ]) + .output() + .expect("failed to execute lineark"); + assert!(output.status.success(), "issues search should succeed"); + let json: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("output should be valid JSON"); + let arr = json.as_array().expect("should be an array"); + if let Some(issue) = arr.first() { + assert!( + issue.get("estimate").is_some(), + "each issue in search output should have an 'estimate' field" + ); + } + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn issues_read_json_includes_estimate_field() { + let token = api_token(); + let unique_name = format!( + "[test] CLI estimate read {}", + &uuid::Uuid::new_v4().to_string()[..8] + ); + + // Get a team key. + let output = lineark() + .args(["--api-token", &token, "--format", "json", "teams", "list"]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "teams list should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let teams: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let team_key = teams[0]["key"].as_str().unwrap().to_string(); + + // Create an issue. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "issues", + "create", + &unique_name, + "--team", + &team_key, + "--priority", + "4", + ]) + .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(), + "issues create 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") + .to_string(); + let _issue_guard = IssueGuard { + token: token.clone(), + id: issue_id.clone(), + }; + + // Read the issue. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "issues", + "read", + &issue_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(), + "issues read should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let detail: serde_json::Value = + serde_json::from_str(&stdout).expect("output should be valid JSON"); + assert!( + detail.get("estimate").is_some(), + "issues read output should have an 'estimate' field" + ); + } }