Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down
35 changes: 35 additions & 0 deletions crates/lineark/src/commands/issues.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ struct IssueRow {
state: String,
assignee: String,
team: String,
estimate: String,
#[tabled(skip)]
url: String,
}
Expand All @@ -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(),
}
}
Expand All @@ -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(),
}
}
Expand All @@ -280,6 +283,7 @@ pub struct IssueSummary {
pub title: Option<String>,
pub priority: Option<f64>,
pub priority_label: Option<String>,
pub estimate: Option<f64>,
pub url: Option<String>,
#[graphql(nested)]
pub state: Option<StateRef>,
Expand All @@ -299,6 +303,7 @@ pub struct SearchSummary {
pub title: Option<String>,
pub priority: Option<f64>,
pub priority_label: Option<String>,
pub estimate: Option<f64>,
pub url: Option<String>,
#[graphql(nested)]
pub state: Option<StateRef>,
Expand Down Expand Up @@ -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<f64>) -> 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<IssueRow> = items.iter().map(|i| IssueRow::from(*i)).collect();
output::print_table(&rows, format);
Expand Down Expand Up @@ -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");
}
}
141 changes: 141 additions & 0 deletions crates/lineark/tests/online.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
}