use serde::Deserialize;
use super::BugzillaClient;
use crate::error::{BzrError, Result, BUGZILLA_INTERNAL_ERROR};
use crate::types::{
partition_filters, ApiMode, Bug, CreateBugParams, HistoryEntry, SearchParams, UpdateBugParams,
BOOLEAN_CHART_FIELD_NAMES,
};
const BUG_DEFAULT_FIELDS: &str = "id,summary,status,resolution,product,component,version,\
assigned_to,priority,severity,creation_time,last_change_time,creator,\
url,whiteboard,keywords,blocks,depends_on,cc,op_sys,rep_platform";
#[derive(Deserialize)]
struct BugListResponse {
bugs: Vec<Bug>,
}
#[derive(Deserialize)]
struct HistoryResponse {
bugs: Vec<HistoryBugEntry>,
}
#[derive(Deserialize)]
struct HistoryBugEntry {
history: Vec<HistoryEntry>,
}
fn append_multi_value_params(
mut builder: reqwest::RequestBuilder,
params: &SearchParams,
) -> reqwest::RequestBuilder {
let fields: &[(&str, &[String])] = &[
("product", ¶ms.product),
("component", ¶ms.component),
("status", ¶ms.status),
("assigned_to", ¶ms.assigned_to),
("creator", ¶ms.creator),
("priority", ¶ms.priority),
("severity", ¶ms.severity),
];
for &(key, values) in fields {
let (positive, _) = partition_filters(values);
for v in positive {
builder = builder.query(&[(key, v)]);
}
}
builder
}
fn append_negated_params(
mut builder: reqwest::RequestBuilder,
params: &SearchParams,
) -> reqwest::RequestBuilder {
let fields: &[(&str, &[String])] = &[
("product", ¶ms.product),
("component", ¶ms.component),
("status", ¶ms.status),
("assigned_to", ¶ms.assigned_to),
("creator", ¶ms.creator),
("priority", ¶ms.priority),
("severity", ¶ms.severity),
];
let mut idx = 1u32;
for &(field_name, values) in fields {
let (_, negated) = partition_filters(values);
let chart_field = BOOLEAN_CHART_FIELD_NAMES
.iter()
.find(|&&(k, _)| k == field_name)
.map_or(field_name, |&(_, v)| v);
for v in negated {
let f_key = format!("f{idx}");
let o_key = format!("o{idx}");
let v_key = format!("v{idx}");
builder = builder.query(&[(&f_key, chart_field), (&o_key, "notequals"), (&v_key, v)]);
idx += 1;
}
}
builder
}
fn append_option_params(
mut builder: reqwest::RequestBuilder,
params: &SearchParams,
) -> reqwest::RequestBuilder {
let option_fields: &[(&str, &Option<String>)] = &[
("cc", ¶ms.cc),
("alias", ¶ms.alias),
("summary", ¶ms.summary),
("quicksearch", ¶ms.quicksearch),
("include_fields", ¶ms.include_fields),
("exclude_fields", ¶ms.exclude_fields),
];
for &(key, value) in option_fields {
if let Some(v) = value {
builder = builder.query(&[(key, v.as_str())]);
}
}
if let Some(limit) = params.limit {
builder = builder.query(&[("limit", limit)]);
}
builder
}
impl BugzillaClient {
pub async fn get_bug_history_since(
&self,
bug_id: u64,
since: Option<&str>,
) -> Result<Vec<HistoryEntry>> {
let data: HistoryResponse = if let Some(since) = since {
self.get_json_query(&format!("bug/{bug_id}/history"), &[("new_since", since)])
.await?
} else {
self.get_json(&format!("bug/{bug_id}/history")).await?
};
let history = data
.bugs
.into_iter()
.next()
.map_or_else(Vec::new, |b| b.history);
Ok(history)
}
pub async fn search_bugs(&self, params: &SearchParams) -> Result<Vec<Bug>> {
tracing::debug!(?params, %self.api_mode, "search parameters");
match self.api_mode {
ApiMode::Rest => self.search_bugs_rest(params).await,
ApiMode::XmlRpc => self.xmlrpc_client()?.search_bugs(params).await,
ApiMode::Hybrid => {
let rest_result = self.search_bugs_rest(params).await;
match rest_result {
Ok(ref bugs) if !bugs.is_empty() => rest_result,
Ok(_) if params.has_filters() => {
tracing::info!(
"REST search returned empty with active filters, \
retrying via XML-RPC"
);
self.xmlrpc_client()?.search_bugs(params).await
}
other => other,
}
}
}
}
async fn search_bugs_rest(&self, params: &SearchParams) -> Result<Vec<Bug>> {
let mut req_builder = self.http.get(self.url("bug"));
req_builder = append_multi_value_params(req_builder, params);
req_builder = append_negated_params(req_builder, params);
req_builder = append_option_params(req_builder, params);
for id in ¶ms.id {
req_builder = req_builder.query(&[("id", id)]);
}
if params.include_fields.is_none() {
req_builder = req_builder.query(&[("include_fields", BUG_DEFAULT_FIELDS)]);
}
let req = self.apply_auth(req_builder);
let resp = self.send(req).await?;
let data: BugListResponse = self.parse_json(resp).await?;
Ok(data.bugs)
}
pub async fn get_bug(
&self,
id: &str,
include_fields: Option<&str>,
exclude_fields: Option<&str>,
) -> Result<Bug> {
match self.api_mode {
ApiMode::XmlRpc => self.xmlrpc_client()?.get_bug(id).await,
ApiMode::Hybrid => {
let rest_result = self.get_bug_rest(id, include_fields, exclude_fields).await;
match &rest_result {
Err(e) if e.is_transport_failure() => {
tracing::info!("REST bug lookup failed, retrying via XML-RPC");
self.xmlrpc_client()?.get_bug(id).await
}
Err(BzrError::Api {
code: BUGZILLA_INTERNAL_ERROR,
..
}) => {
tracing::info!(
"REST bug lookup returned 100500, \
retrying via XML-RPC"
);
self.xmlrpc_client()?.get_bug(id).await
}
_ => rest_result,
}
}
ApiMode::Rest => self.get_bug_rest(id, include_fields, exclude_fields).await,
}
}
async fn get_bug_rest(
&self,
id: &str,
include_fields: Option<&str>,
exclude_fields: Option<&str>,
) -> Result<Bug> {
let fields = include_fields.unwrap_or(BUG_DEFAULT_FIELDS);
let mut req_builder = self
.http
.get(self.url(&format!("bug/{id}")))
.query(&[("include_fields", fields)]);
if let Some(fields) = exclude_fields {
req_builder = req_builder.query(&[("exclude_fields", fields)]);
}
let req = self.apply_auth(req_builder);
let resp = self.send(req).await?;
let result: Result<BugListResponse> = self.parse_json(resp).await;
if let Err(BzrError::Api {
code: BUGZILLA_INTERNAL_ERROR,
..
}) = &result
{
tracing::debug!("direct bug lookup returned 100500, retrying via search endpoint");
return self.get_bug_via_search(id, fields, exclude_fields).await;
}
result?
.bugs
.into_iter()
.next()
.ok_or_else(|| BzrError::NotFound {
resource: "bug",
id: id.to_string(),
})
}
async fn get_bug_via_search(
&self,
id: &str,
include_fields: &str,
exclude_fields: Option<&str>,
) -> Result<Bug> {
let mut req_builder = self
.http
.get(self.url("bug"))
.query(&[("id", id), ("include_fields", include_fields)]);
if let Some(fields) = exclude_fields {
req_builder = req_builder.query(&[("exclude_fields", fields)]);
}
let req = self.apply_auth(req_builder);
let resp = self.send(req).await?;
let data: BugListResponse = self.parse_json(resp).await?;
data.bugs
.into_iter()
.next()
.ok_or_else(|| BzrError::NotFound {
resource: "bug",
id: id.to_string(),
})
}
pub async fn create_bug(&self, params: &CreateBugParams) -> Result<u64> {
self.post_json_id("bug", params).await
}
pub async fn update_bug(&self, id: u64, updates: &UpdateBugParams) -> Result<()> {
self.put_json(&format!("bug/{id}"), updates).await
}
}
#[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests {
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
use crate::client::test_helpers::{test_client, test_client_hybrid};
#[tokio::test]
async fn get_bug_history_returns_entries() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/42/history"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{
"id": 42,
"alias": [],
"history": [
{
"who": "alice@example.com",
"when": "2025-01-15T10:30:00Z",
"changes": [
{
"field_name": "status",
"removed": "NEW",
"added": "ASSIGNED"
},
{
"field_name": "assigned_to",
"removed": "",
"added": "alice@example.com"
}
]
},
{
"who": "bob@example.com",
"when": "2025-01-16T14:00:00Z",
"changes": [
{
"field_name": "status",
"removed": "ASSIGNED",
"added": "RESOLVED"
}
]
}
]
}]
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let history = client.get_bug_history_since(42, None).await.unwrap();
assert_eq!(history.len(), 2);
assert_eq!(history[0].who, "alice@example.com");
assert_eq!(history[0].changes.len(), 2);
assert_eq!(history[0].changes[0].field_name, "status");
assert_eq!(history[0].changes[0].removed, "NEW");
assert_eq!(history[0].changes[0].added, "ASSIGNED");
assert_eq!(history[1].changes.len(), 1);
}
#[tokio::test]
async fn get_bug_history_empty() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/99/history"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{"id": 99, "alias": [], "history": []}]
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let history = client.get_bug_history_since(99, None).await.unwrap();
assert!(history.is_empty());
}
#[tokio::test]
async fn get_bug_history_with_attachment_id() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/10/history"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{
"id": 10,
"alias": [],
"history": [{
"who": "carol@example.com",
"when": "2025-02-01T09:00:00Z",
"changes": [{
"field_name": "attachments.isobsolete",
"removed": "0",
"added": "1",
"attachment_id": 555
}]
}]
}]
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let history = client.get_bug_history_since(10, None).await.unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].changes[0].attachment_id, Some(555));
}
#[tokio::test]
async fn get_bug_history_since_filters_by_date() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/42/history"))
.and(query_param("new_since", "2025-06-01T00:00:00Z"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{
"id": 42,
"alias": [],
"history": [{
"who": "alice@example.com",
"when": "2025-06-15T10:00:00Z",
"changes": [{"field_name": "status", "removed": "NEW", "added": "ASSIGNED"}]
}]
}]
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let history = client
.get_bug_history_since(42, Some("2025-06-01T00:00:00Z"))
.await
.unwrap();
assert_eq!(history.len(), 1);
}
#[tokio::test]
async fn get_bug_passes_params() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/1"))
.and(query_param("include_fields", "id,summary"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"bugs": [{"id": 1, "summary": "test", "status": "NEW"}]}),
))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let bug = client.get_bug("1", Some("id,summary"), None).await.unwrap();
assert_eq!(bug.id, 1);
}
#[tokio::test]
async fn get_bug_falls_back_on_100500() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/99"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"error": true,
"code": 100_500,
"message": "Extension crash"
})))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("id", "99"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"bugs": [{"id": 99, "summary": "fallback bug", "status": "NEW"}]}),
))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let bug = client.get_bug("99", None, None).await.unwrap();
assert_eq!(bug.id, 99);
assert_eq!(bug.summary, "fallback bug");
}
#[tokio::test]
async fn search_bugs_sends_option_fields() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("cc", "user@example.com"))
.and(query_param("alias", "my-alias"))
.and(query_param("summary", "crash"))
.and(query_param("limit", "25"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": []
})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
cc: Some("user@example.com".into()),
alias: Some("my-alias".into()),
summary: Some("crash".into()),
limit: Some(25),
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert!(bugs.is_empty());
}
#[tokio::test]
async fn search_bugs_sends_product_filter() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("product", "Product"))
.and(query_param("limit", "50"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{
"id": 217_630,
"summary": "Test bug",
"status": "WORKING",
"product": "Product",
"component": "Triage",
"assigned_to": "test@example.com",
"priority": "P1",
"severity": "high",
"creation_time": "2026-03-09T09:33:08Z",
"last_change_time": "2026-03-18T05:49:05Z"
}]
})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
product: vec!["Product".into()],
limit: Some(50),
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert_eq!(bugs.len(), 1);
assert_eq!(bugs[0].id, 217_630);
}
use crate::test_helpers::xmlrpc_bug_response;
#[tokio::test]
async fn hybrid_search_rest_has_results_no_xmlrpc_call() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{"id": 1, "summary": "REST bug", "status": "NEW"}]
})))
.expect(1)
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&mock)
.await;
let client = test_client_hybrid(&mock.uri());
let params = SearchParams {
product: vec!["P".into()],
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert_eq!(bugs.len(), 1);
assert_eq!(bugs[0].summary, "REST bug");
}
#[tokio::test]
async fn hybrid_search_rest_empty_with_filters_falls_back_to_xmlrpc() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.expect(1)
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.respond_with(
ResponseTemplate::new(200).set_body_string(xmlrpc_bug_response(99, "XML-RPC bug")),
)
.expect(1)
.mount(&mock)
.await;
let client = test_client_hybrid(&mock.uri());
let params = SearchParams {
product: vec!["P".into()],
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert_eq!(bugs.len(), 1);
assert_eq!(bugs[0].id, 99);
assert_eq!(bugs[0].summary, "XML-RPC bug");
}
#[tokio::test]
async fn hybrid_search_rest_empty_without_filters_no_fallback() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.expect(1)
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&mock)
.await;
let client = test_client_hybrid(&mock.uri());
let params = SearchParams::default();
let bugs = client.search_bugs(¶ms).await.unwrap();
assert!(bugs.is_empty());
}
#[tokio::test]
async fn hybrid_get_bug_rest_500_falls_back_to_xmlrpc() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/42"))
.respond_with(ResponseTemplate::new(500).set_body_string("error"))
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(xmlrpc_bug_response(42, "XML-RPC result")),
)
.expect(1)
.mount(&mock)
.await;
let client = test_client_hybrid(&mock.uri());
let bug = client.get_bug("42", None, None).await.unwrap();
assert_eq!(bug.id, 42);
assert_eq!(bug.summary, "XML-RPC result");
}
#[tokio::test]
async fn hybrid_get_bug_rest_401_does_not_fall_back() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug/42"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": true,
"code": 102,
"message": "Invalid API key"
})))
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&mock)
.await;
let client = test_client_hybrid(&mock.uri());
let err = client.get_bug("42", None, None).await.unwrap_err();
assert!(
err.to_string().contains("Invalid API key"),
"should propagate auth error, got: {err}"
);
}
#[test]
fn search_params_has_filters() {
let empty = SearchParams::default();
assert!(!empty.has_filters());
let with_product = SearchParams {
product: vec!["P".into()],
..Default::default()
};
assert!(with_product.has_filters());
let with_quicksearch = SearchParams {
quicksearch: Some("crash".into()),
..Default::default()
};
assert!(with_quicksearch.has_filters());
}
#[tokio::test]
async fn search_bugs_multi_value_sends_repeated_params() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("status", "NEW"))
.and(query_param("status", "ASSIGNED"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{"id": 1, "summary": "Bug 1", "status": "NEW"}]
})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
status: vec!["NEW".into(), "ASSIGNED".into()],
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert_eq!(bugs.len(), 1);
}
#[tokio::test]
async fn search_bugs_negation_sends_boolean_chart() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("f1", "bug_status"))
.and(query_param("o1", "notequals"))
.and(query_param("v1", "CLOSED"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{"id": 2, "summary": "Open bug", "status": "NEW"}]
})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
status: vec!["!CLOSED".into()],
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert_eq!(bugs.len(), 1);
}
#[tokio::test]
async fn search_bugs_mixed_positive_and_negated() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("status", "NEW"))
.and(query_param("f1", "bug_severity"))
.and(query_param("o1", "notequals"))
.and(query_param("v1", "enhancement"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{"id": 3, "summary": "Real bug", "status": "NEW"}]
})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
status: vec!["NEW".into()],
severity: vec!["!enhancement".into()],
..Default::default()
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert_eq!(bugs.len(), 1);
}
#[tokio::test]
async fn search_bugs_all_fields_reach_server() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(query_param("product", "Firefox"))
.and(query_param("component", "General"))
.and(query_param("status", "NEW"))
.and(query_param("assigned_to", "dev@test.com"))
.and(query_param("creator", "reporter@test.com"))
.and(query_param("priority", "P1"))
.and(query_param("severity", "major"))
.and(query_param("cc", "watcher@test.com"))
.and(query_param("alias", "my-bug"))
.and(query_param("id", "42"))
.and(query_param("limit", "10"))
.and(query_param("summary", "crash"))
.and(query_param("quicksearch", "qs-term"))
.and(query_param("include_fields", "id,summary"))
.and(query_param("exclude_fields", "cc"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"bugs": [{"id": 42, "summary": "crash", "status": "NEW"}]
})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = SearchParams {
product: vec!["Firefox".into()],
component: vec!["General".into()],
status: vec!["NEW".into()],
assigned_to: vec!["dev@test.com".into()],
creator: vec!["reporter@test.com".into()],
priority: vec!["P1".into()],
severity: vec!["major".into()],
cc: Some("watcher@test.com".into()),
alias: Some("my-bug".into()),
id: vec![42],
limit: Some(10),
summary: Some("crash".into()),
quicksearch: Some("qs-term".into()),
include_fields: Some("id,summary".into()),
exclude_fields: Some("cc".into()),
};
let bugs = client.search_bugs(¶ms).await.unwrap();
assert_eq!(bugs.len(), 1);
}
}