use std::time::Duration;
use serde::Deserialize;
use super::BugzillaClient;
use crate::error::{BzrError, Result, BUGZILLA_INTERNAL_ERROR};
use crate::http::XMLRPC_FALLBACK_TIMEOUT;
use crate::types::{
partition_filters, ApiMode, Bug, CreateBugParams, HistoryEntry, SearchParams, UpdateBugParams,
FIELD_MAPPINGS,
};
const BUG_DEFAULT_FIELDS: &str = "id,summary,status,resolution,dupe_of,product,component,version,\
assigned_to,priority,severity,creation_time,last_change_time,creator,\
url,whiteboard,keywords,blocks,depends_on,cc,op_sys,rep_platform,deadline";
fn force_id_fields(
include: Option<&str>,
exclude: Option<&str>,
) -> (Option<String>, Option<String>) {
let has_id = |list: &str| list.split(',').any(|t| t.trim().eq_ignore_ascii_case("id"));
let include = include.map(|list| {
if has_id(list) {
list.to_string()
} else {
format!("id,{list}")
}
});
let exclude = exclude.and_then(|list| {
if !has_id(list) {
return Some(list.to_string());
}
let kept: Vec<&str> = list
.split(',')
.filter(|t| !t.trim().eq_ignore_ascii_case("id"))
.collect();
if kept.is_empty() {
None
} else {
Some(kept.join(","))
}
});
(include, exclude)
}
#[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 {
for mapping in FIELD_MAPPINGS {
let (positive, _) =
partition_filters(params.get_field(mapping.struct_field).unwrap_or_default());
for v in positive {
builder = builder.query(&[(mapping.struct_field, v)]);
}
}
builder
}
fn append_negated_params(
mut builder: reqwest::RequestBuilder,
params: &SearchParams,
) -> reqwest::RequestBuilder {
let mut idx = 1u32;
for mapping in FIELD_MAPPINGS {
let (_, negated) =
partition_filters(params.get_field(mapping.struct_field).unwrap_or_default());
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, mapping.internal_name),
(&o_key, mapping.negation_operator.as_str()),
(&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),
("creation_time", ¶ms.creation_time),
("last_change_time", ¶ms.last_change_time),
];
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
}
fn has_negated_filters(params: &SearchParams) -> bool {
FIELD_MAPPINGS.iter().any(|m| {
params
.get_field(m.struct_field)
.unwrap_or_default()
.iter()
.any(|v| v.starts_with('!'))
})
}
fn has_raw_boolean_chart_params(params: &SearchParams) -> bool {
params.raw_params.iter().any(|(k, _)| {
k.len() >= 2
&& matches!(k.as_bytes()[0], b'f' | b'o' | b'v')
&& k[1..].parse::<u32>().is_ok_and(|n| n >= 1)
})
}
fn append_raw_params(
builder: reqwest::RequestBuilder,
raw_params: &[(String, String)],
) -> reqwest::RequestBuilder {
builder.query(raw_params)
}
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");
let (inc, exc) = force_id_fields(
params.include_fields.as_deref(),
params.exclude_fields.as_deref(),
);
let normalized =
(inc != params.include_fields || exc != params.exclude_fields).then(|| SearchParams {
include_fields: inc,
exclude_fields: exc,
..params.clone()
});
let params = normalized.as_ref().unwrap_or(params);
if !params.raw_params.is_empty() && self.api_mode != ApiMode::Rest {
tracing::warn!(
"query contains raw URL parameters that require REST API; \
ignoring configured {} mode",
self.api_mode
);
return self.search_bugs_rest(params).await;
}
match self.api_mode {
ApiMode::Rest => self.search_bugs_rest(params).await,
ApiMode::XmlRpc => self.xmlrpc_client()?.search_bugs(params).await,
ApiMode::Hybrid => {
self.search_bugs_hybrid(params, XMLRPC_FALLBACK_TIMEOUT)
.await
}
}
}
pub(crate) async fn search_bugs_hybrid(
&self,
params: &SearchParams,
fallback_timeout: Duration,
) -> Result<Vec<Bug>> {
let rest_bugs = self.search_bugs_rest(params).await?;
if !rest_bugs.is_empty() || !params.has_structured_filters() {
return Ok(rest_bugs);
}
tracing::info!(
"REST search returned empty with active structured filters, \
retrying via XML-RPC"
);
let xmlrpc = self.xmlrpc_client()?;
if let Ok(result) = tokio::time::timeout(fallback_timeout, xmlrpc.search_bugs(params)).await
{
result
} else {
tracing::warn!(
"XML-RPC search fallback timed out after {}s — returning the \
empty REST result. To skip future fallbacks for this server, \
pass --api rest or set api_mode = \"rest\" in config.",
fallback_timeout.as_secs()
);
Ok(rest_bugs)
}
}
async fn search_bugs_rest(&self, params: &SearchParams) -> Result<Vec<Bug>> {
if has_negated_filters(params) && has_raw_boolean_chart_params(params) {
return Err(crate::error::BzrError::InputValidation(
"cannot combine negated filters (e.g. --status '!CLOSED') with a \
URL-imported query containing boolean chart parameters; the chart \
indices would collide"
.into(),
));
}
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)]);
}
req_builder = append_raw_params(req_builder, ¶ms.raw_params);
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> {
let (inc, exc) = force_id_fields(include_fields, exclude_fields);
let (include_fields, exclude_fields) = (inc.as_deref(), exc.as_deref());
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)]
#[path = "bug_tests.rs"]
mod tests;