use owo_colors::OwoColorize;
use crate::api::{
ApiError, Issue, IssueDraft, IssueLink, IssueUpdate, JiraClient, Version, escape_jql,
};
use crate::output::{OutputConfig, use_color};
#[derive(Default)]
pub struct ListFilters<'a> {
pub project: Option<&'a str>,
pub status: Option<&'a str>,
pub assignee: Option<&'a str>,
pub issue_type: Option<&'a str>,
pub sprint: Option<&'a str>,
pub components: Option<&'a [&'a str]>,
pub labels: Option<&'a [&'a str]>,
pub fix_versions: Option<&'a [&'a str]>,
pub jql_extra: Option<&'a str>,
}
pub async fn list(
client: &JiraClient,
out: &OutputConfig,
filters: ListFilters<'_>,
limit: usize,
offset: usize,
all: bool,
) -> Result<(), ApiError> {
let jql = build_list_jql(&filters);
if all {
let issues = fetch_all_issues(client, &jql).await?;
let n = issues.len();
render_results(out, &issues, Some(n), 0, n, client, false);
} else {
let resp = client.search(&jql, limit, offset).await?;
let more = !resp.is_last;
render_results(
out,
&resp.issues,
resp.total,
resp.start_at,
resp.max_results,
client,
more,
);
}
Ok(())
}
pub async fn mine(
client: &JiraClient,
out: &OutputConfig,
mut filters: ListFilters<'_>,
limit: usize,
all: bool,
) -> Result<(), ApiError> {
filters.assignee = Some("me");
list(client, out, filters, limit, 0, all).await
}
pub async fn comments(client: &JiraClient, out: &OutputConfig, key: &str) -> Result<(), ApiError> {
let issue = client.get_issue(key).await?;
let comment_list = issue.fields.comment.as_ref();
if out.json {
let comments_json: Vec<serde_json::Value> = comment_list
.map(|cl| {
cl.comments
.iter()
.map(|c| {
serde_json::json!({
"id": c.id,
"author": {
"displayName": c.author.display_name,
"accountId": c.author.account_id,
},
"body": c.body_text(),
"created": c.created,
"updated": c.updated,
})
})
.collect()
})
.unwrap_or_default();
let total = comment_list.map(|cl| cl.total).unwrap_or(0);
out.print_data(
&serde_json::to_string_pretty(&serde_json::json!({
"issue": key,
"total": total,
"comments": comments_json,
}))
.expect("failed to serialize JSON"),
);
} else {
match comment_list {
None => {
out.print_message(&format!("No comments on {key}."));
}
Some(cl) if cl.comments.is_empty() => {
out.print_message(&format!("No comments on {key}."));
}
Some(cl) => {
let color = use_color();
out.print_message(&format!("Comments on {key} ({}):", cl.total));
for c in &cl.comments {
println!();
let author = if color {
c.author.display_name.bold().to_string()
} else {
c.author.display_name.clone()
};
println!(" {} — {}", author, format_date(&c.created));
for line in c.body_text().lines() {
println!(" {line}");
}
}
}
}
}
Ok(())
}
pub async fn fetch_all_issues(client: &JiraClient, jql: &str) -> Result<Vec<Issue>, ApiError> {
const PAGE_SIZE: usize = 100;
let mut all: Vec<Issue> = Vec::new();
let mut offset = 0;
loop {
let resp = client.search(jql, PAGE_SIZE, offset).await?;
let fetched = resp.issues.len();
all.extend(resp.issues);
offset += fetched;
if resp.is_last || fetched == 0 {
break;
}
}
Ok(all)
}
fn render_results(
out: &OutputConfig,
issues: &[Issue],
total: Option<usize>,
start_at: usize,
max_results: usize,
client: &JiraClient,
more: bool,
) {
if out.json {
let total_json: serde_json::Value = match total {
Some(n) => serde_json::json!(n),
None => serde_json::Value::Null,
};
out.print_data(
&serde_json::to_string_pretty(&serde_json::json!({
"total": total_json,
"startAt": start_at,
"maxResults": max_results,
"issues": issues.iter().map(|i| issue_to_json(i, client)).collect::<Vec<_>>(),
}))
.expect("failed to serialize JSON"),
);
} else {
render_issue_table(issues, out);
if more {
match total {
Some(n) => out.print_message(&format!(
"Showing {}-{} of {} issues — use --limit/--offset or --all to paginate",
start_at + 1,
start_at + issues.len(),
n
)),
None => out.print_message(&format!(
"Showing {}-{} issues (more available) — use --limit/--offset or --all to paginate",
start_at + 1,
start_at + issues.len()
)),
}
} else {
out.print_message(&format!("{} issues", issues.len()));
}
}
}
pub async fn show(
client: &JiraClient,
out: &OutputConfig,
key: &str,
open: bool,
) -> Result<(), ApiError> {
let issue = client.get_issue(key).await?;
if open {
open_in_browser(&client.browse_url(&issue.key));
}
if out.json {
out.print_data(
&serde_json::to_string_pretty(&issue_detail_to_json(&issue, client))
.expect("failed to serialize JSON"),
);
} else {
render_issue_detail(&issue);
}
Ok(())
}
pub async fn create(
client: &JiraClient,
out: &OutputConfig,
draft: &IssueDraft<'_>,
sprint: Option<&str>,
custom_fields: &[(String, serde_json::Value)],
) -> Result<(), ApiError> {
let resp = client.create_issue(draft, custom_fields).await?;
let url = client.browse_url(&resp.key);
let mut result = serde_json::json!({ "key": resp.key, "id": resp.id, "url": url });
if let Some(p) = draft.parent {
result["parent"] = serde_json::json!(p);
}
if let Some(s) = sprint {
let resolved = client.resolve_sprint(s).await?;
client.move_issue_to_sprint(&resp.key, resolved.id).await?;
result["sprintId"] = serde_json::json!(resolved.id);
result["sprintName"] = serde_json::json!(resolved.name);
}
out.print_result(&result, &resp.key);
Ok(())
}
pub async fn update(
client: &JiraClient,
out: &OutputConfig,
key: &str,
update: &IssueUpdate<'_>,
custom_fields: &[(String, serde_json::Value)],
) -> Result<(), ApiError> {
client.update_issue(key, update, custom_fields).await?;
out.print_result(
&serde_json::json!({ "key": key, "updated": true }),
&format!("Updated {key}"),
);
Ok(())
}
pub async fn move_to_sprint(
client: &JiraClient,
out: &OutputConfig,
key: &str,
sprint: &str,
) -> Result<(), ApiError> {
let resolved = client.resolve_sprint(sprint).await?;
client.move_issue_to_sprint(key, resolved.id).await?;
out.print_result(
&serde_json::json!({
"issue": key,
"sprintId": resolved.id,
"sprintName": resolved.name,
}),
&format!("Moved {key} to {} ({})", resolved.name, resolved.id),
);
Ok(())
}
pub async fn comment(
client: &JiraClient,
out: &OutputConfig,
key: &str,
body: &str,
) -> Result<(), ApiError> {
let c = client.add_comment(key, body).await?;
let url = client.browse_url(key);
out.print_result(
&serde_json::json!({
"id": c.id,
"issue": key,
"url": url,
"author": c.author.display_name,
"created": c.created,
}),
&format!("Comment added to {key}"),
);
Ok(())
}
pub async fn transition(
client: &JiraClient,
out: &OutputConfig,
key: &str,
to: &str,
) -> Result<(), ApiError> {
let transitions = client.get_transitions(key).await?;
let matched = transitions
.iter()
.find(|t| t.name.to_lowercase() == to.to_lowercase() || t.id == to);
match matched {
Some(t) => {
let name = t.name.clone();
let id = t.id.clone();
let status =
t.to.as_ref()
.map(|tt| tt.name.clone())
.unwrap_or_else(|| name.clone());
client.do_transition(key, &id).await?;
out.print_result(
&serde_json::json!({ "issue": key, "transition": name, "status": status, "id": id }),
&format!("Transitioned {key} → {status}"),
);
}
None => {
let hint = transitions
.iter()
.map(|t| format!(" {} ({})", t.name, t.id))
.collect::<Vec<_>>()
.join("\n");
out.print_message(&format!(
"Transition '{to}' not found for {key}. Available:\n{hint}"
));
out.print_message(&format!(
"Tip: `jira issues list-transitions {key}` shows transitions as JSON."
));
return Err(ApiError::NotFound(format!(
"Transition '{to}' not found for {key}"
)));
}
}
Ok(())
}
pub async fn list_transitions(
client: &JiraClient,
out: &OutputConfig,
key: &str,
) -> Result<(), ApiError> {
let ts = client.get_transitions(key).await?;
if out.json {
out.print_data(&serde_json::to_string_pretty(&ts).expect("failed to serialize JSON"));
} else {
let color = use_color();
let header = format!("{:<6} {}", "ID", "Name");
if color {
println!("{}", header.bold());
} else {
println!("{header}");
}
for t in &ts {
println!("{:<6} {}", t.id, t.name);
}
}
Ok(())
}
pub async fn assign(
client: &JiraClient,
out: &OutputConfig,
key: &str,
assignee: &str,
) -> Result<(), ApiError> {
let account_id = if assignee == "me" {
let me = client.get_myself().await?;
me.account_id
} else if assignee == "none" || assignee == "unassign" {
client.assign_issue(key, None).await?;
out.print_result(
&serde_json::json!({ "issue": key, "assignee": null }),
&format!("Unassigned {key}"),
);
return Ok(());
} else {
assignee.to_string()
};
client.assign_issue(key, Some(&account_id)).await?;
out.print_result(
&serde_json::json!({ "issue": key, "accountId": account_id }),
&format!("Assigned {key} to {assignee}"),
);
Ok(())
}
pub async fn link_types(client: &JiraClient, out: &OutputConfig) -> Result<(), ApiError> {
let types = client.get_link_types().await?;
if out.json {
out.print_data(
&serde_json::to_string_pretty(&serde_json::json!(
types
.iter()
.map(|t| serde_json::json!({
"id": t.id,
"name": t.name,
"inward": t.inward,
"outward": t.outward,
}))
.collect::<Vec<_>>()
))
.expect("failed to serialize JSON"),
);
return Ok(());
}
for t in &types {
println!(
"{:<20} outward: {} / inward: {}",
t.name, t.outward, t.inward
);
}
Ok(())
}
pub async fn link(
client: &JiraClient,
out: &OutputConfig,
from_key: &str,
to_key: &str,
link_type: &str,
) -> Result<(), ApiError> {
client.link_issues(from_key, to_key, link_type).await?;
out.print_result(
&serde_json::json!({
"from": from_key,
"to": to_key,
"type": link_type,
}),
&format!("Linked {from_key} → {to_key} ({link_type})"),
);
Ok(())
}
pub async fn unlink(
client: &JiraClient,
out: &OutputConfig,
link_id: &str,
) -> Result<(), ApiError> {
client.unlink_issues(link_id).await?;
out.print_result(
&serde_json::json!({ "linkId": link_id }),
&format!("Removed link {link_id}"),
);
Ok(())
}
pub async fn log_work(
client: &JiraClient,
out: &OutputConfig,
key: &str,
time_spent: &str,
comment: Option<&str>,
started: Option<&str>,
) -> Result<(), ApiError> {
let entry = client.log_work(key, time_spent, comment, started).await?;
out.print_result(
&serde_json::json!({
"id": entry.id,
"issue": key,
"timeSpent": entry.time_spent,
"timeSpentSeconds": entry.time_spent_seconds,
"author": entry.author.display_name,
"started": entry.started,
"created": entry.created,
}),
&format!("Logged {} on {key}", entry.time_spent),
);
Ok(())
}
pub async fn bulk_transition(
client: &JiraClient,
out: &OutputConfig,
jql: &str,
to: &str,
dry_run: bool,
) -> Result<(), ApiError> {
let issues = fetch_all_issues(client, jql).await?;
if issues.is_empty() {
out.print_message("No issues matched the query.");
return Ok(());
}
let mut results: Vec<serde_json::Value> = Vec::new();
let mut succeeded = 0usize;
let mut failed = 0usize;
for issue in &issues {
if dry_run {
results.push(serde_json::json!({
"key": issue.key,
"status": issue.status(),
"action": "would transition",
"to": to,
}));
continue;
}
let transitions = client.get_transitions(&issue.key).await?;
let matched = transitions.iter().find(|t| {
t.name.eq_ignore_ascii_case(to)
|| t.to
.as_ref()
.is_some_and(|tt| tt.name.eq_ignore_ascii_case(to))
|| t.id == to
});
match matched {
Some(t) => match client.do_transition(&issue.key, &t.id).await {
Ok(()) => {
succeeded += 1;
results.push(serde_json::json!({
"key": issue.key,
"from": issue.status(),
"to": to,
"ok": true,
}));
}
Err(e) => {
failed += 1;
results.push(serde_json::json!({
"key": issue.key,
"ok": false,
"error": e.to_string(),
}));
}
},
None => {
failed += 1;
results.push(serde_json::json!({
"key": issue.key,
"ok": false,
"error": format!("transition '{to}' not available"),
}));
}
}
}
if out.json {
out.print_data(
&serde_json::to_string_pretty(&serde_json::json!({
"dryRun": dry_run,
"total": issues.len(),
"succeeded": succeeded,
"failed": failed,
"issues": results,
}))
.expect("failed to serialize JSON"),
);
} else if dry_run {
render_issue_table(&issues, out);
out.print_message(&format!(
"Dry run: {} issues would be transitioned to '{to}'",
issues.len()
));
} else {
out.print_message(&format!(
"Transitioned {succeeded}/{} issues to '{to}'{}",
issues.len(),
if failed > 0 {
format!(" ({failed} failed)")
} else {
String::new()
}
));
}
Ok(())
}
pub async fn bulk_assign(
client: &JiraClient,
out: &OutputConfig,
jql: &str,
assignee: &str,
dry_run: bool,
) -> Result<(), ApiError> {
let account_id: Option<String> = match assignee {
"me" => {
let me = client.get_myself().await?;
Some(me.account_id)
}
"none" | "unassign" => None,
id => Some(id.to_string()),
};
let issues = fetch_all_issues(client, jql).await?;
if issues.is_empty() {
out.print_message("No issues matched the query.");
return Ok(());
}
let mut results: Vec<serde_json::Value> = Vec::new();
let mut succeeded = 0usize;
let mut failed = 0usize;
for issue in &issues {
if dry_run {
results.push(serde_json::json!({
"key": issue.key,
"currentAssignee": issue.assignee(),
"action": "would assign",
"to": assignee,
}));
continue;
}
match client.assign_issue(&issue.key, account_id.as_deref()).await {
Ok(()) => {
succeeded += 1;
results.push(serde_json::json!({
"key": issue.key,
"assignee": assignee,
"ok": true,
}));
}
Err(e) => {
failed += 1;
results.push(serde_json::json!({
"key": issue.key,
"ok": false,
"error": e.to_string(),
}));
}
}
}
if out.json {
out.print_data(
&serde_json::to_string_pretty(&serde_json::json!({
"dryRun": dry_run,
"total": issues.len(),
"succeeded": succeeded,
"failed": failed,
"issues": results,
}))
.expect("failed to serialize JSON"),
);
} else if dry_run {
render_issue_table(&issues, out);
out.print_message(&format!(
"Dry run: {} issues would be assigned to '{assignee}'",
issues.len()
));
} else {
out.print_message(&format!(
"Assigned {succeeded}/{} issues to '{assignee}'{}",
issues.len(),
if failed > 0 {
format!(" ({failed} failed)")
} else {
String::new()
}
));
}
Ok(())
}
pub(crate) fn render_issue_table(issues: &[Issue], out: &OutputConfig) {
if issues.is_empty() {
out.print_message("No issues found.");
return;
}
let color = use_color();
let term_width = terminal_width();
let key_w = issues.iter().map(|i| i.key.len()).max().unwrap_or(4).max(4) + 1;
let status_w = issues
.iter()
.map(|i| i.status().len())
.max()
.unwrap_or(6)
.clamp(6, 14)
+ 2;
let assignee_w = issues
.iter()
.map(|i| i.assignee().len())
.max()
.unwrap_or(8)
.clamp(8, 18)
+ 2;
let type_w = issues
.iter()
.map(|i| i.issue_type().len())
.max()
.unwrap_or(4)
.clamp(4, 12)
+ 2;
let fixed = key_w + 1 + status_w + 1 + assignee_w + 1 + type_w + 1;
let summary_w = term_width.saturating_sub(fixed).max(20);
let header = format!(
"{:<key_w$} {:<status_w$} {:<assignee_w$} {:<type_w$} {}",
"Key", "Status", "Assignee", "Type", "Summary"
);
if color {
println!("{}", header.bold());
} else {
println!("{header}");
}
for issue in issues {
let key = if color {
format!("{:<key_w$}", issue.key).yellow().to_string()
} else {
format!("{:<key_w$}", issue.key)
};
let status_val = truncate(issue.status(), status_w - 2);
let status = if color {
colorize_status(issue.status(), &format!("{:<status_w$}", status_val))
} else {
format!("{:<status_w$}", status_val)
};
println!(
"{key} {status} {:<assignee_w$} {:<type_w$} {}",
truncate(issue.assignee(), assignee_w - 2),
truncate(issue.issue_type(), type_w - 2),
truncate(issue.summary(), summary_w),
);
}
}
fn render_issue_detail(issue: &Issue) {
let mut stdout = std::io::stdout().lock();
write_issue_detail(&mut stdout, issue).expect("stdout write");
}
fn write_issue_detail<W: std::io::Write>(out: &mut W, issue: &Issue) -> std::io::Result<()> {
let color = use_color();
let key = if color {
issue.key.yellow().bold().to_string()
} else {
issue.key.clone()
};
writeln!(out, "{key} {}", issue.summary())?;
writeln!(out)?;
writeln!(out, " Type: {}", issue.issue_type())?;
let status_str = if color {
colorize_status(issue.status(), issue.status())
} else {
issue.status().to_string()
};
writeln!(out, " Status: {status_str}")?;
writeln!(out, " Priority: {}", issue.priority())?;
writeln!(out, " Assignee: {}", issue.assignee())?;
if let Some(ref reporter) = issue.fields.reporter {
writeln!(out, " Reporter: {}", reporter.display_name)?;
}
if let Some(ref labels) = issue.fields.labels
&& !labels.is_empty()
{
writeln!(out, " Labels: {}", labels.join(", "))?;
}
if let Some(ref components) = issue.fields.components
&& !components.is_empty()
{
let names: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
writeln!(out, " Components: {}", names.join(", "))?;
}
if let Some(ref fix_versions) = issue.fields.fix_versions
&& !fix_versions.is_empty()
{
let names: Vec<&str> = fix_versions.iter().map(|v| v.name.as_str()).collect();
writeln!(out, " Fix Versions: {}", names.join(", "))?;
}
if let Some(ref versions) = issue.fields.versions
&& !versions.is_empty()
{
let names: Vec<&str> = versions.iter().map(|v| v.name.as_str()).collect();
writeln!(out, " Affects Versions: {}", names.join(", "))?;
}
if let Some(ref created) = issue.fields.created {
writeln!(out, " Created: {}", format_date(created))?;
}
if let Some(ref updated) = issue.fields.updated {
writeln!(out, " Updated: {}", format_date(updated))?;
}
let desc = issue.description_text();
if !desc.is_empty() {
writeln!(out)?;
writeln!(out, "Description:")?;
for line in desc.lines() {
writeln!(out, " {line}")?;
}
}
if let Some(ref links) = issue.fields.issue_links
&& !links.is_empty()
{
writeln!(out)?;
writeln!(out, "Links:")?;
for link in links {
write_issue_link(out, link)?;
}
}
if let Some(ref comment_list) = issue.fields.comment
&& !comment_list.comments.is_empty()
{
writeln!(out)?;
writeln!(out, "Comments ({}):", comment_list.total)?;
for c in &comment_list.comments {
writeln!(out)?;
let author = if color {
c.author.display_name.bold().to_string()
} else {
c.author.display_name.clone()
};
writeln!(out, " {} — {}", author, format_date(&c.created))?;
let body = c.body_text();
for line in body.lines() {
writeln!(out, " {line}")?;
}
}
}
Ok(())
}
fn write_issue_link<W: std::io::Write>(out: &mut W, link: &IssueLink) -> std::io::Result<()> {
if let Some(ref out_issue) = link.outward_issue {
writeln!(
out,
" [{}] {} {} — {}",
link.id, link.link_type.outward, out_issue.key, out_issue.fields.summary
)?;
}
if let Some(ref in_issue) = link.inward_issue {
writeln!(
out,
" [{}] {} {} — {}",
link.id, link.link_type.inward, in_issue.key, in_issue.fields.summary
)?;
}
Ok(())
}
pub(crate) fn issue_to_json(issue: &Issue, client: &JiraClient) -> serde_json::Value {
serde_json::json!({
"key": issue.key,
"id": issue.id,
"url": client.browse_url(&issue.key),
"summary": issue.summary(),
"status": issue.status(),
"assignee": {
"displayName": issue.assignee(),
"accountId": issue.fields.assignee.as_ref().and_then(|a| a.account_id.as_deref()),
},
"priority": issue.priority(),
"type": issue.issue_type(),
"created": issue.fields.created,
"updated": issue.fields.updated,
})
}
fn version_to_json(v: &Version) -> serde_json::Value {
serde_json::json!({
"id": v.id,
"name": v.name,
"description": v.description,
"released": v.released,
"archived": v.archived,
"releaseDate": v.release_date,
})
}
pub fn issue_detail_to_json(issue: &Issue, client: &JiraClient) -> serde_json::Value {
let comments: Vec<serde_json::Value> = issue
.fields
.comment
.as_ref()
.map(|cl| {
cl.comments
.iter()
.map(|c| {
serde_json::json!({
"id": c.id,
"author": {
"displayName": c.author.display_name,
"accountId": c.author.account_id,
},
"body": c.body_text(),
"created": c.created,
"updated": c.updated,
})
})
.collect()
})
.unwrap_or_default();
let issue_links: Vec<serde_json::Value> = issue
.fields
.issue_links
.as_deref()
.unwrap_or_default()
.iter()
.map(|link| {
let sentence = if let Some(ref out_issue) = link.outward_issue {
format!("{} {} {}", issue.key, link.link_type.outward, out_issue.key)
} else if let Some(ref in_issue) = link.inward_issue {
format!("{} {} {}", issue.key, link.link_type.inward, in_issue.key)
} else {
String::new()
};
serde_json::json!({
"id": link.id,
"sentence": sentence,
"type": {
"id": link.link_type.id,
"name": link.link_type.name,
"inward": link.link_type.inward,
"outward": link.link_type.outward,
},
"outwardIssue": link.outward_issue.as_ref().map(|i| serde_json::json!({
"key": i.key,
"summary": i.fields.summary,
"status": i.fields.status.name,
})),
"inwardIssue": link.inward_issue.as_ref().map(|i| serde_json::json!({
"key": i.key,
"summary": i.fields.summary,
"status": i.fields.status.name,
})),
})
})
.collect();
serde_json::json!({
"key": issue.key,
"id": issue.id,
"url": client.browse_url(&issue.key),
"summary": issue.summary(),
"status": issue.status(),
"type": issue.issue_type(),
"priority": issue.priority(),
"assignee": {
"displayName": issue.assignee(),
"accountId": issue.fields.assignee.as_ref().and_then(|a| a.account_id.as_deref()),
},
"reporter": issue.fields.reporter.as_ref().map(|r| serde_json::json!({
"displayName": r.display_name,
"accountId": r.account_id,
})),
"labels": issue.fields.labels,
"components": issue.fields.components,
"fixVersions": issue.fields.fix_versions.as_ref().map(|fvs| {
fvs.iter().map(version_to_json).collect::<Vec<_>>()
}),
"affectedVersions": issue.fields.versions.as_ref().map(|vs| {
vs.iter().map(version_to_json).collect::<Vec<_>>()
}),
"description": issue.description_text(),
"created": issue.fields.created,
"updated": issue.fields.updated,
"comments": comments,
"issueLinks": issue_links,
})
}
fn jql_multi_value(field: &str, values: &[&str]) -> Option<String> {
match values.len() {
0 => None,
1 => Some(format!(r#"{field} = "{}""#, escape_jql(values[0]))),
_ => {
let quoted: Vec<String> = values
.iter()
.map(|v| format!(r#""{}""#, escape_jql(v)))
.collect();
Some(format!("{field} in ({})", quoted.join(", ")))
}
}
}
fn build_list_jql(filters: &ListFilters<'_>) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(p) = filters.project {
parts.push(format!(r#"project = "{}""#, escape_jql(p)));
}
if let Some(s) = filters.status {
parts.push(format!(r#"status = "{}""#, escape_jql(s)));
}
if let Some(a) = filters.assignee {
if a == "me" {
parts.push("assignee = currentUser()".into());
} else {
parts.push(format!(r#"assignee = "{}""#, escape_jql(a)));
}
}
if let Some(t) = filters.issue_type {
parts.push(format!(r#"issuetype = "{}""#, escape_jql(t)));
}
if let Some(s) = filters.sprint {
if s == "active" || s == "open" {
parts.push("sprint in openSprints()".into());
} else {
parts.push(format!(r#"sprint = "{}""#, escape_jql(s)));
}
}
if let Some(comps) = filters.components {
parts.extend(jql_multi_value("component", comps));
}
if let Some(lbls) = filters.labels {
parts.extend(jql_multi_value("labels", lbls));
}
if let Some(fvs) = filters.fix_versions {
parts.extend(jql_multi_value("fixVersion", fvs));
}
if let Some(e) = filters.jql_extra {
parts.push(format!("({e})"));
}
if parts.is_empty() {
"ORDER BY updated DESC".into()
} else {
format!("{} ORDER BY updated DESC", parts.join(" AND "))
}
}
fn colorize_status(status: &str, display: &str) -> String {
let lower = status.to_lowercase();
if lower.contains("done") || lower.contains("closed") || lower.contains("resolved") {
display.green().to_string()
} else if lower.contains("progress") || lower.contains("review") || lower.contains("testing") {
display.yellow().to_string()
} else if lower.contains("blocked") || lower.contains("impediment") {
display.red().to_string()
} else {
display.to_string()
}
}
fn open_in_browser(url: &str) {
#[cfg(target_os = "macos")]
let result = std::process::Command::new("open").arg(url).status();
#[cfg(target_os = "linux")]
let result = std::process::Command::new("xdg-open").arg(url).status();
#[cfg(target_os = "windows")]
let result = std::process::Command::new("cmd")
.args(["/c", "start", url])
.status();
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
if let Err(e) = result {
eprintln!("Warning: could not open browser: {e}");
}
}
fn truncate(s: &str, max: usize) -> String {
let mut chars = s.chars();
let mut result: String = chars.by_ref().take(max).collect();
if chars.next().is_some() {
result.push('…');
}
result
}
fn format_date(s: &str) -> String {
s.chars().take(10).collect()
}
const MIN_TERMINAL_WIDTH: usize = 60;
const DEFAULT_TERMINAL_WIDTH: usize = 120;
fn terminal_width() -> usize {
use std::io::IsTerminal;
let tty_width = std::io::stdout()
.is_terminal()
.then(terminal_size::terminal_size)
.flatten()
.map(|(terminal_size::Width(w), _)| w as usize);
let columns = std::env::var("COLUMNS").ok().and_then(|v| v.parse().ok());
resolve_terminal_width(tty_width, columns)
}
fn resolve_terminal_width(tty_width: Option<usize>, columns: Option<usize>) -> usize {
if let Some(w) = tty_width {
return w.max(MIN_TERMINAL_WIDTH);
}
columns.unwrap_or(DEFAULT_TERMINAL_WIDTH)
}
pub async fn resolve_assignee_arg(
client: &JiraClient,
arg: Option<&str>,
) -> Result<Option<Option<String>>, ApiError> {
match arg {
None => Ok(None),
Some("none") => Ok(Some(None)),
Some("me") => {
let me = client.get_myself().await?;
Ok(Some(Some(me.account_id)))
}
Some(id) => Ok(Some(Some(id.to_string()))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::types::{IssueFields, IssueTypeField, StatusField, Version};
fn issue_fixture(fix: Option<Vec<Version>>, aff: Option<Vec<Version>>) -> Issue {
Issue {
id: "10001".into(),
key: "PROJ-1".into(),
url: None,
fields: IssueFields {
summary: "Test".into(),
status: StatusField {
name: "Open".into(),
},
assignee: None,
reporter: None,
priority: None,
issuetype: IssueTypeField { name: "Bug".into() },
description: None,
labels: None,
components: None,
fix_versions: fix,
versions: aff,
created: None,
updated: None,
comment: None,
issue_links: None,
},
}
}
fn make_version(id: &str, name: &str) -> Version {
Version {
id: id.into(),
name: name.into(),
description: None,
released: None,
archived: None,
release_date: None,
}
}
#[test]
fn write_issue_detail_renders_fix_versions_line() {
let issue = issue_fixture(
Some(vec![make_version("1", "1.2.0"), make_version("2", "1.3.0")]),
None,
);
let mut buf = Vec::new();
write_issue_detail(&mut buf, &issue).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(
out.contains(" Fix Versions: 1.2.0, 1.3.0"),
"expected rendered fix-versions line, got:\n{out}"
);
}
#[test]
fn write_issue_detail_renders_affects_versions_line() {
let issue = issue_fixture(None, Some(vec![make_version("5", "1.1.0")]));
let mut buf = Vec::new();
write_issue_detail(&mut buf, &issue).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(
out.contains(" Affects Versions: 1.1.0"),
"expected affects-versions line, got:\n{out}"
);
}
#[test]
fn write_issue_detail_omits_version_lines_when_empty() {
let issue = issue_fixture(Some(vec![]), None);
let mut buf = Vec::new();
write_issue_detail(&mut buf, &issue).unwrap();
let out = String::from_utf8(buf).unwrap();
assert!(
!out.contains("Fix Versions:"),
"should omit fix versions header for empty slice, got:\n{out}"
);
assert!(
!out.contains("Affects Versions:"),
"should omit affects versions header when None, got:\n{out}"
);
}
#[test]
fn truncate_short_string() {
assert_eq!(truncate("hello", 10), "hello");
}
#[test]
fn truncate_exact_length() {
assert_eq!(truncate("hello", 5), "hello");
}
#[test]
fn truncate_long_string() {
assert_eq!(truncate("hello world", 5), "hello…");
}
#[test]
fn truncate_multibyte_safe() {
let result = truncate("日本語テスト", 3);
assert_eq!(result, "日本語…");
}
#[test]
fn build_list_jql_empty() {
assert_eq!(
build_list_jql(&ListFilters::default()),
"ORDER BY updated DESC"
);
}
#[test]
fn build_list_jql_escapes_quotes() {
let jql = build_list_jql(&ListFilters {
status: Some(r#"Done" OR 1=1"#),
..Default::default()
});
assert!(jql.contains(r#"\""#), "double quote must be escaped");
assert!(
jql.contains(r#"status = "Done\""#),
"escaped quote must remain inside the status value string"
);
}
#[test]
fn build_list_jql_project_and_status() {
let jql = build_list_jql(&ListFilters {
project: Some("PROJ"),
status: Some("In Progress"),
..Default::default()
});
assert!(jql.contains(r#"project = "PROJ""#));
assert!(jql.contains(r#"status = "In Progress""#));
}
#[test]
fn build_list_jql_assignee_me() {
let jql = build_list_jql(&ListFilters {
assignee: Some("me"),
..Default::default()
});
assert!(jql.contains("currentUser()"));
}
#[test]
fn build_list_jql_issue_type() {
let jql = build_list_jql(&ListFilters {
issue_type: Some("Bug"),
..Default::default()
});
assert!(jql.contains(r#"issuetype = "Bug""#));
}
#[test]
fn build_list_jql_sprint_active() {
let jql = build_list_jql(&ListFilters {
sprint: Some("active"),
..Default::default()
});
assert!(jql.contains("sprint in openSprints()"));
}
#[test]
fn build_list_jql_sprint_named() {
let jql = build_list_jql(&ListFilters {
sprint: Some("Sprint 42"),
..Default::default()
});
assert!(jql.contains(r#"sprint = "Sprint 42""#));
}
#[test]
fn build_list_jql_single_component() {
let jql = build_list_jql(&ListFilters {
components: Some(&["Backend"]),
..Default::default()
});
assert!(
jql.contains(r#"component = "Backend""#),
"expected single-component clause, got: {jql}"
);
}
#[test]
fn build_list_jql_multiple_components() {
let jql = build_list_jql(&ListFilters {
components: Some(&["Backend", "API"]),
..Default::default()
});
assert!(
jql.contains(r#"component in ("Backend", "API")"#),
"expected `component in (...)` clause, got: {jql}"
);
}
#[test]
fn build_list_jql_escapes_component_quotes() {
let jql = build_list_jql(&ListFilters {
components: Some(&[r#"weird "name""#]),
..Default::default()
});
assert!(
jql.contains(r#"component = "weird \"name\"""#),
"expected escaped quotes, got: {jql}"
);
}
#[test]
fn build_list_jql_empty_components_emits_no_clause() {
let jql = build_list_jql(&ListFilters {
components: Some(&[]),
..Default::default()
});
assert!(
!jql.contains("component"),
"expected no component clause for empty slice, got: {jql}"
);
}
#[test]
fn build_list_jql_single_label() {
let jql = build_list_jql(&ListFilters {
labels: Some(&["backend"]),
..Default::default()
});
assert!(
jql.contains(r#"labels = "backend""#),
"expected single-label clause, got: {jql}"
);
}
#[test]
fn build_list_jql_multiple_labels() {
let jql = build_list_jql(&ListFilters {
labels: Some(&["backend", "urgent"]),
..Default::default()
});
assert!(
jql.contains(r#"labels in ("backend", "urgent")"#),
"expected `labels in (...)` clause, got: {jql}"
);
}
#[test]
fn build_list_jql_escapes_label_quotes() {
let jql = build_list_jql(&ListFilters {
labels: Some(&[r#"weird "name""#]),
..Default::default()
});
assert!(
jql.contains(r#"labels = "weird \"name\"""#),
"expected escaped quotes, got: {jql}"
);
}
#[test]
fn build_list_jql_empty_labels_emits_no_clause() {
let jql = build_list_jql(&ListFilters {
labels: Some(&[]),
..Default::default()
});
assert!(
!jql.contains("labels"),
"expected no labels clause for empty slice, got: {jql}"
);
}
#[test]
fn build_list_jql_single_fix_version() {
let jql = build_list_jql(&ListFilters {
fix_versions: Some(&["1.2.0"]),
..Default::default()
});
assert!(
jql.contains(r#"fixVersion = "1.2.0""#),
"expected single fixVersion clause, got: {jql}"
);
}
#[test]
fn build_list_jql_multiple_fix_versions() {
let jql = build_list_jql(&ListFilters {
fix_versions: Some(&["1.2.0", "1.3.0"]),
..Default::default()
});
assert!(
jql.contains(r#"fixVersion in ("1.2.0", "1.3.0")"#),
"expected fixVersion in (...) clause, got: {jql}"
);
}
#[test]
fn build_list_jql_escapes_fix_version_quotes() {
let jql = build_list_jql(&ListFilters {
fix_versions: Some(&[r#"weird "ver""#]),
..Default::default()
});
assert!(
jql.contains(r#"fixVersion = "weird \"ver\"""#),
"expected escaped quotes, got: {jql}"
);
}
#[test]
fn build_list_jql_empty_fix_versions_emits_no_clause() {
let jql = build_list_jql(&ListFilters {
fix_versions: Some(&[]),
..Default::default()
});
assert!(
!jql.contains("fixVersion"),
"expected no fixVersion clause for empty slice, got: {jql}"
);
}
#[test]
fn colorize_status_done_is_green() {
let result = colorize_status("Done", "Done");
assert!(result.contains("Done"));
assert!(result.contains("\x1b["));
}
#[test]
fn colorize_status_unknown_unchanged() {
let result = colorize_status("Backlog", "Backlog");
assert_eq!(result, "Backlog");
}
struct EnvVarGuard(&'static str);
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe { std::env::remove_var(self.0) }
}
}
#[test]
fn terminal_width_fallback_parses_columns() {
unsafe { std::env::set_var("COLUMNS", "200") };
let _guard = EnvVarGuard("COLUMNS");
assert_eq!(terminal_width(), 200);
}
#[test]
fn resolve_terminal_width_prefers_tty_over_columns() {
assert_eq!(resolve_terminal_width(Some(200), Some(80)), 200);
}
#[test]
fn resolve_terminal_width_clamps_narrow_tty_to_minimum() {
assert_eq!(resolve_terminal_width(Some(40), None), MIN_TERMINAL_WIDTH);
}
#[test]
fn resolve_terminal_width_does_not_clamp_columns_fallback() {
assert_eq!(resolve_terminal_width(None, Some(40)), 40);
}
#[test]
fn resolve_terminal_width_defaults_when_nothing_available() {
assert_eq!(resolve_terminal_width(None, None), DEFAULT_TERMINAL_WIDTH);
}
#[tokio::test]
async fn resolve_assignee_arg_absent_returns_none() {
let server = wiremock::MockServer::start().await;
let client = crate::api::JiraClient::new(
&server.uri(),
"test@example.com",
"test-token",
crate::api::AuthType::Basic,
3,
)
.unwrap();
let result = resolve_assignee_arg(&client, None).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn resolve_assignee_arg_none_sentinel_returns_some_none() {
let server = wiremock::MockServer::start().await;
let client = crate::api::JiraClient::new(
&server.uri(),
"test@example.com",
"test-token",
crate::api::AuthType::Basic,
3,
)
.unwrap();
let result = resolve_assignee_arg(&client, Some("none")).await.unwrap();
assert!(matches!(result, Some(None)));
}
#[tokio::test]
async fn resolve_assignee_arg_literal_id_passes_through() {
let server = wiremock::MockServer::start().await;
let client = crate::api::JiraClient::new(
&server.uri(),
"test@example.com",
"test-token",
crate::api::AuthType::Basic,
3,
)
.unwrap();
let result = resolve_assignee_arg(&client, Some("literal-id-999"))
.await
.unwrap();
assert_eq!(result, Some(Some("literal-id-999".to_string())));
}
}