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
120 changes: 120 additions & 0 deletions src/cli/issue/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,126 @@ pub(super) async fn resolve_assignee_by_project(
)
}

/// Resolve an `--asset` flag value to an object key.
///
/// - Value matches `SCHEMA-NUMBER` key pattern → return as-is (no API call)
/// - Otherwise → search Assets by name via AQL, disambiguate if multiple matches
///
/// Returns the resolved object key (e.g., `"OBJ-18"`).
pub(super) async fn resolve_asset(
client: &JiraClient,
input: &str,
no_input: bool,
) -> Result<String> {
// Key pattern → passthrough (no API call)
if crate::jql::validate_asset_key(input).is_ok() {
return Ok(input.to_string());
}

// Name search: fetch workspace ID, then AQL search
let workspace_id = crate::api::assets::workspace::get_or_fetch_workspace_id(client).await?;
let escaped = crate::jql::escape_value(input);
let aql = format!("Name like \"{}\"", escaped);
let results = client
.search_assets(&workspace_id, &aql, Some(25), false)
.await?;

if results.is_empty() {
anyhow::bail!(
"No assets matching \"{}\" found. Check the name and try again.",
input
);
}

if results.len() == 1 {
return Ok(results.into_iter().next().unwrap().object_key);
}

// Multiple results — disambiguate via partial_match on labels
let labels: Vec<String> = results.iter().map(|a| a.label.clone()).collect();
match crate::partial_match::partial_match(input, &labels) {
crate::partial_match::MatchResult::Exact(matched_label) => {
let asset = results
.iter()
.find(|a| a.label == matched_label)
.expect("matched label must exist in results");
Ok(asset.object_key.clone())
}
crate::partial_match::MatchResult::ExactMultiple(_) => {
// Multiple assets with same label — need key to disambiguate
let label_lower = input.to_lowercase();
let duplicates: Vec<_> = results
.iter()
.filter(|a| a.label.to_lowercase() == label_lower)
.collect();

if no_input {
let lines: Vec<String> = duplicates
.iter()
.map(|a| format!(" {} ({})", a.object_key, a.label))
.collect();
anyhow::bail!(
"Multiple assets match \"{}\":\n{}\nUse a more specific name or pass the object key directly.",
input,
lines.join("\n")
);
}

let items: Vec<String> = duplicates
.iter()
.map(|a| format!("{} ({})", a.object_key, a.label))
.collect();
let selection = dialoguer::Select::new()
.with_prompt(format!("Multiple assets match \"{}\"", input))
.items(&items)
.interact()?;
Ok(duplicates[selection].object_key.clone())
}
crate::partial_match::MatchResult::Ambiguous(matches) => {
let filtered: Vec<_> = results
.iter()
.filter(|a| matches.contains(&a.label))
.collect();

if no_input {
let lines: Vec<String> = filtered
.iter()
.map(|a| format!(" {} ({})", a.object_key, a.label))
.collect();
anyhow::bail!(
"Multiple assets match \"{}\":\n{}\nUse a more specific name or pass the object key directly.",
input,
lines.join("\n")
);
}

let items: Vec<String> = filtered
.iter()
.map(|a| format!("{} ({})", a.object_key, a.label))
.collect();
let selection = dialoguer::Select::new()
.with_prompt(format!("Multiple assets match \"{}\"", input))
.items(&items)
.interact()?;
Ok(filtered[selection].object_key.clone())
}
crate::partial_match::MatchResult::None(_) => {
// AQL returned results but partial_match found no substring match.
// This shouldn't normally happen (AQL already filtered by Name like),
// but handle gracefully.
let lines: Vec<String> = results
.iter()
.map(|a| format!(" {} ({})", a.object_key, a.label))
.collect();
anyhow::bail!(
"No assets with a name matching \"{}\" found. Similar results:\n{}\nUse the object key directly.",
input,
lines.join("\n")
);
}
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
14 changes: 8 additions & 6 deletions src/cli/issue/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,7 @@ pub(super) async fn handle_list(
crate::jql::validate_duration(d).map_err(JrError::UserError)?;
}

// Validate --asset key format early
if let Some(ref key) = asset_key {
crate::jql::validate_asset_key(key).map_err(JrError::UserError)?;
}

// Validate date filter flags early
// Validate date filter flags early (before any network calls)
let created_after_date = if let Some(ref d) = created_after {
Some(crate::jql::validate_date(d).map_err(JrError::UserError)?)
} else {
Expand Down Expand Up @@ -133,6 +128,13 @@ pub(super) async fn handle_list(
format!("updated < \"{}\"", next_day)
});

// Resolve --asset: key passthrough or name → key via AQL search
let asset_key = if let Some(raw) = asset_key {
Some(helpers::resolve_asset(client, &raw, no_input).await?)
} else {
None
};

// Resolve --assignee and --reporter to JQL values
let assignee_jql = if let Some(ref name) = assignee {
Some(helpers::resolve_user(client, name, no_input).await?)
Expand Down
239 changes: 239 additions & 0 deletions tests/cli_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -602,3 +602,242 @@ async fn test_handler_list_created_before() {
.assert()
.success();
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_handler_list_asset_name_resolves_to_key() {
let server = MockServer::start().await;
let cache_dir = tempfile::tempdir().unwrap();

// 1. Workspace discovery
Mock::given(method("GET"))
.and(path("/rest/servicedeskapi/assets/workspace"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"size": 1,
"start": 0,
"limit": 50,
"isLastPage": true,
"values": [{ "workspaceId": "ws-123" }]
})))
.mount(&server)
.await;

// 2. AQL search — returns single match
Mock::given(method("POST"))
.and(path("/jsm/assets/workspace/ws-123/v1/object/aql"))
.and(query_param("includeAttributes", "false"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"startAt": 0,
"maxResults": 25,
"total": 1,
"isLast": true,
"values": [{
"id": "70",
"label": "Acme Corp",
"objectKey": "OBJ-70",
"objectType": { "id": "13", "name": "Client" }
}]
})))
.mount(&server)
.await;

// 3. CMDB fields discovery
Mock::given(method("GET"))
.and(path("/rest/api/3/field"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"id": "customfield_10191",
"name": "Client",
"custom": true,
"schema": {
"type": "any",
"custom": "com.atlassian.jira.plugins.cmdb:cmdb-object-cftype",
"customId": 10191
}
}
])))
.mount(&server)
.await;

// 4. Project check
Mock::given(method("GET"))
.and(path("/rest/api/3/project/PROJ"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"key": "PROJ",
"id": "10000",
"name": "Test Project"
})))
.mount(&server)
.await;

// 5. Issue search — verify JQL uses resolved key OBJ-70
Mock::given(method("POST"))
.and(path("/rest/api/3/search/jql"))
.and(body_partial_json(serde_json::json!({
"jql": "project = \"PROJ\" AND \"Client\" IN aqlFunction(\"Key = \\\"OBJ-70\\\"\") ORDER BY updated DESC"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(
common::fixtures::issue_search_response(vec![common::fixtures::issue_response(
"PROJ-1",
"Test issue",
"To Do",
)]),
))
.expect(1)
.mount(&server)
.await;

Command::cargo_bin("jr")
.unwrap()
.env("JR_BASE_URL", server.uri())
.env("JR_AUTH_HEADER", "Basic dGVzdDp0ZXN0")
.env("XDG_CACHE_HOME", cache_dir.path())
.args([
"issue",
"list",
"--project",
"PROJ",
"--asset",
"Acme",
"--no-input",
])
.assert()
.success();
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_handler_list_asset_name_no_match_errors() {
let server = MockServer::start().await;
let cache_dir = tempfile::tempdir().unwrap();

// 1. Workspace discovery
Mock::given(method("GET"))
.and(path("/rest/servicedeskapi/assets/workspace"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"size": 1,
"start": 0,
"limit": 50,
"isLastPage": true,
"values": [{ "workspaceId": "ws-123" }]
})))
.mount(&server)
.await;

// 2. AQL search — returns zero matches
Mock::given(method("POST"))
.and(path("/jsm/assets/workspace/ws-123/v1/object/aql"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"startAt": 0,
"maxResults": 25,
"total": 0,
"isLast": true,
"values": []
})))
.mount(&server)
.await;

Command::cargo_bin("jr")
.unwrap()
.env("JR_BASE_URL", server.uri())
.env("JR_AUTH_HEADER", "Basic dGVzdDp0ZXN0")
.env("XDG_CACHE_HOME", cache_dir.path())
.args([
"issue",
"list",
"--project",
"PROJ",
"--asset",
"Nonexistent",
"--no-input",
])
.assert()
.failure()
.stderr(predicate::str::contains(
"No assets matching \"Nonexistent\" found",
));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_handler_list_asset_key_passthrough_skips_assets_api() {
let server = MockServer::start().await;
let cache_dir = tempfile::tempdir().unwrap();

// Direct asset keys should NOT trigger workspace discovery
Mock::given(method("GET"))
.and(path("/rest/servicedeskapi/assets/workspace"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"size": 1, "start": 0, "limit": 50, "isLastPage": true,
"values": [{ "workspaceId": "ws-123" }]
})))
.expect(0)
.mount(&server)
.await;

// Direct asset keys should NOT trigger AQL search
Mock::given(method("POST"))
.and(path("/jsm/assets/workspace/ws-123/v1/object/aql"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"startAt": 0, "maxResults": 25, "total": 0, "isLast": true, "values": []
})))
.expect(0)
.mount(&server)
.await;

// CMDB fields discovery (still needed for build_asset_clause)
Mock::given(method("GET"))
.and(path("/rest/api/3/field"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"id": "customfield_10191",
"name": "Client",
"custom": true,
"schema": {
"type": "any",
"custom": "com.atlassian.jira.plugins.cmdb:cmdb-object-cftype",
"customId": 10191
}
}
])))
.mount(&server)
.await;

// Project check
Mock::given(method("GET"))
.and(path("/rest/api/3/project/PROJ"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"key": "PROJ", "id": "10000", "name": "Test Project"
})))
.mount(&server)
.await;

// Issue search — verify JQL uses provided key OBJ-18 directly
Mock::given(method("POST"))
.and(path("/rest/api/3/search/jql"))
.and(body_partial_json(serde_json::json!({
"jql": "project = \"PROJ\" AND \"Client\" IN aqlFunction(\"Key = \\\"OBJ-18\\\"\") ORDER BY updated DESC"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(
common::fixtures::issue_search_response(vec![common::fixtures::issue_response(
"PROJ-1", "Test issue", "To Do",
)]),
))
.expect(1)
.mount(&server)
.await;

Command::cargo_bin("jr")
.unwrap()
.env("JR_BASE_URL", server.uri())
.env("JR_AUTH_HEADER", "Basic dGVzdDp0ZXN0")
.env("XDG_CACHE_HOME", cache_dir.path())
.args([
"issue",
"list",
"--project",
"PROJ",
"--asset",
"OBJ-18",
"--no-input",
])
.assert()
.success();
}