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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -677,18 +677,18 @@ none of the three needs an API key. Every other tool does, including
| `list_attachments` | Attachment metadata (never attachment content) | `attachments` |
| `download_attachment` | Content of one attachment, alongside a JSON summary of its metadata: raster images (PNG, JPEG, GIF, WebP, BMP) as image content, everything else as a base64 blob resource under `bugzilla://attachment/{id}`. Capped by `max_attachment_bytes`; private attachments need the private-content double opt-in and, on download, a *missing* privacy flag counts as private | `attachments` on the owning bug |
| `add_comment` | Add a comment to a bug, optionally private | `comment` |
| `update_bug_status` | Change status/resolution. CLOSED requires a resolution; reopening to any status other than CLOSED or VERIFIED without naming one clears the resolution | `status` |
| `update_bug_status` | Change status and, optionally, resolution — both instance-defined; use `bug_fields` to discover them. Bugzilla requires a resolution when the target status is closing and the bug has none, and clears any resolution itself when the target status is open | `status` |
| `assign_bug` | Set the assignee | `assign` |
| `update_bug_fields` | Update priority/severity/resolution, summary, URL, whiteboard, version, target milestone, keywords and see-also links (both add/remove, never replace-all), and `cf_*` custom fields | `fields` on the bug **and** at least `summary` on every see-also target on this instance |
| `update_bug_dependencies` | Add/remove blocks and depends_on entries | `deps` |
| `add_cc_to_bug` | Add an email to the CC list (the tool only adds; removal is not exposed) | `cc` |
| `mark_as_duplicate` | Close a bug as DUPLICATE of another, with an auto-generated comment when none is given | `status` on the bug **and** at least `summary` on the duplicate target |
| `mark_as_duplicate` | Mark a bug as DUPLICATE of another, with an auto-generated comment when none is given; Bugzilla applies its configured duplicate status | `status` on the bug **and** at least `summary` on the duplicate target |
| `create_bug` | File a new bug, including `cf_*` custom fields; the request is policy-checked *as described* before anything is created. A policy refusal and a Bugzilla-side failure return the same refusal text at the same cost, so a failed create never says which of the two refused, or why | `create` on the bug as requested |
| `add_attachment` | Upload a base64-encoded attachment to a bug, optionally private or flagged as a patch, capped by `max_attachment_bytes` (decoded size) | `attach` on the target bug |
| `bug_url` | Compute `{server}/show_bug.cgi?id={id}` locally | none (contacts nothing) |
| `bugzilla_server_info` | Bugzilla version, extensions, timezone, time, parameters | none |
| `bugzilla_products` | *(needs `global.allow_discovery = true`)* Lists enterable product names, or fetches components/versions/milestones for up to 5 named products — as Bugzilla reports it to this server's key, never filtered by this policy | none |
| `bug_fields` | *(needs `global.allow_discovery = true`)* Lists bug fields (without legal values), or fetches up to 5 named fields with their legal values — as Bugzilla reports it to this server's key, never filtered by this policy | none |
| `bug_fields` | *(needs `global.allow_discovery = true`)* Lists bug fields (without legal values), or fetches up to 5 named fields with their legal values — each carrying `is_open` and `can_change_to` when Bugzilla reports them (only `bug_status` does), so a client can learn the workflow instead of guessing it — as Bugzilla reports it to this server's key, never filtered by this policy | none |
| `quicksearch_syntax` | Bugzilla's quicksearch syntax documentation (HTML) | none |
| `mcp_server_info` | This server's name and version, the Bugzilla URL, the transport, and a coarse policy summary: rule count, default action, `min_bug_age_days`, read-only flag, disabled tool names. Never a rule name or a match criterion | none |

Expand Down
61 changes: 42 additions & 19 deletions crates/bugwarden/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -726,15 +726,46 @@ fn project_field_catalog(envelope: &Value, on_bug_entry_only: bool) -> Vec<Value
.unwrap_or_default()
}

/// Project one `can_change_to` entry (only Bugzilla's `bug_status` field
/// carries these) to `{name, comment_required}`; `comment_required` is
/// omitted when the upstream entry does not carry it.
fn project_can_change_to(c: &Value) -> Option<Value> {
let name = c.get("name")?.clone();
let mut obj = serde_json::Map::new();
obj.insert("name".to_string(), name);
if let Some(comment_required) = c.get("comment_required") {
obj.insert("comment_required".to_string(), comment_required.clone());
}
Some(Value::Object(obj))
}

/// Project one legal value of a `/rest/field/bug/{name}` response to
/// `{name}`, plus `is_open` and `can_change_to` when the upstream value
/// carries them (only `bug_status` does). Absent keys are omitted, never
/// `null`, so non-workflow fields stay as cheap as before.
fn project_field_value(v: &Value) -> Option<Value> {
let name = v.get("name")?.clone();
let mut obj = serde_json::Map::new();
obj.insert("name".to_string(), name);
if let Some(is_open) = v.get("is_open") {
obj.insert("is_open".to_string(), is_open.clone());
}
if let Some(list) = v.get("can_change_to").and_then(Value::as_array) {
let projected: Vec<Value> = list.iter().filter_map(project_can_change_to).collect();
obj.insert("can_change_to".to_string(), Value::Array(projected));
}
Some(Value::Object(obj))
}

/// Project one `/rest/field/bug/{name}` response to the detail shape
/// (`bug_fields` with `field_names` named): [`project_field_common`] plus
/// `values`, reduced to the legal value NAMES only.
/// `values`, each reduced to [`project_field_value`].
fn project_field_detail(f: &Value) -> Value {
let mut obj = project_field_common(f);
let values: Vec<Value> = f
.get("values")
.and_then(Value::as_array)
.map(|vs| vs.iter().filter_map(|v| v.get("name").cloned()).collect())
.map(|vs| vs.iter().filter_map(project_field_value).collect())
.unwrap_or_default();
if let Value::Object(map) = &mut obj {
map.insert("values".to_string(), Value::Array(values));
Expand Down Expand Up @@ -1012,7 +1043,9 @@ pub struct UpdateBugStatusParams {
pub bug_id: u64,
/// New status.
pub status: String,
/// Resolution (required when status is CLOSED).
/// Resolution. Bugzilla requires this when the target status is
/// closing and the bug has none, and clears it automatically when
/// the target status is open.
#[serde(default)]
pub resolution: Option<String>,
/// Optional comment explaining the change.
Expand Down Expand Up @@ -2198,7 +2231,7 @@ impl BugWarden {
}

#[tool(
description = "Update the status of a bug. Optionally add a comment explaining the status change.\n\nValid statuses: NEW, ASSIGNED, MODIFIED, ON_QA, VERIFIED, CLOSED.\nFor CLOSED, you MUST also provide a resolution (FIXED, WONTFIX, NOTABUG, DUPLICATE, etc.)",
description = "Update the status of a bug. Optionally add a comment explaining the status change.\n\nStatuses and resolutions are instance-defined; use bug_fields with field_names: [\"bug_status\"] to list them along with their openness and legal transitions where the operator has enabled discovery. A closing status needs a resolution unless the bug already has one; Bugzilla clears the resolution itself when moving to an open status.",
annotations(
read_only_hint = false,
destructive_hint = true,
Expand All @@ -2217,13 +2250,6 @@ impl BugWarden {
resolution = ?p.resolution,
"tool: update_bug_status"
);
let has_resolution = p.resolution.as_deref().is_some_and(|r| !r.is_empty());
if p.status == "CLOSED" && !has_resolution {
note_refused(&ctx);
return Ok(err_text(
"Resolution is required when setting status to CLOSED (e.g., FIXED, WONTFIX, NOTABUG, DUPLICATE)",
));
}
let key = self.api_key(&ctx)?;
let caller = self.guard.resolve_caller(&self.bz, &key).await;
if let Some(denied) = self
Expand All @@ -2235,11 +2261,8 @@ impl BugWarden {

let mut payload = serde_json::Map::new();
payload.insert("status".to_string(), json!(p.status));
if has_resolution {
payload.insert("resolution".to_string(), json!(p.resolution));
} else if p.status != "CLOSED" && p.status != "VERIFIED" {
// Clear resolution when reopening.
payload.insert("resolution".to_string(), json!(""));
if let Some(resolution) = p.resolution.as_deref().filter(|r| !r.is_empty()) {
payload.insert("resolution".to_string(), json!(resolution));
}
attach_comment(&mut payload, &p.comment);

Expand Down Expand Up @@ -2572,7 +2595,7 @@ impl BugWarden {
}

#[tool(
description = "Mark a bug as a duplicate of another bug and close it.",
description = "Mark a bug as a duplicate of another bug. Bugzilla applies its configured duplicate status and the DUPLICATE resolution.",
annotations(
read_only_hint = false,
destructive_hint = true,
Expand Down Expand Up @@ -2630,9 +2653,9 @@ impl BugWarden {
} else {
p.comment.clone()
};
// Setting dupe_of alone is enough: Bugzilla's set_dup_id applies the
// instance's duplicate_or_move_bug_status and resolution DUPLICATE.
let mut payload = serde_json::Map::new();
payload.insert("status".to_string(), json!("CLOSED"));
payload.insert("resolution".to_string(), json!("DUPLICATE"));
payload.insert("dupe_of".to_string(), json!(p.duplicate_of));
attach_comment(&mut payload, &comment);

Expand Down
161 changes: 158 additions & 3 deletions crates/bugwarden/tests/tools_wiremock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,109 @@ async fn update_fields_all_empty_call_errors_without_calling_bugzilla() {
);
}

/// Read the sole PUT body received by `mock`, asserting exactly one PUT
/// reached it. Used to assert *absence* of keys, not just presence.
async fn sole_put_body(mock: &MockServer) -> Value {
mock.received_requests()
.await
.unwrap()
.iter()
.find(|r| r.method == wiremock::http::Method::PUT)
.map(|r| serde_json::from_slice(&r.body).expect("PUT body is JSON"))
.expect("one PUT reached the mock")
}

#[tokio::test]
async fn update_bug_status_without_resolution_omits_it_from_the_wire() {
// Bugzilla rejects a synthesised "resolution":"" on RESOLVED
// (missing_resolution); the tool must not send it.
let mock = MockServer::start().await;
mount_classify(&mock, world_readable_bug(7)).await;
mount_update_put(&mock, json!({ "status": "RESOLVED" })).await;
let client = client_for("", &mock).await;
let result = call(
&client,
"update_bug_status",
json!({ "bug_id": 7, "status": "RESOLVED" }),
)
.await;
assert!(!is_error(&result), "result: {}", text_of(&result));
assert_eq!(sole_put_body(&mock).await, json!({ "status": "RESOLVED" }));
}

#[tokio::test]
async fn update_bug_status_with_resolution_sends_both() {
let mock = MockServer::start().await;
mount_classify(&mock, world_readable_bug(7)).await;
mount_update_put(
&mock,
json!({ "status": "RESOLVED", "resolution": "FIXED" }),
)
.await;
let client = client_for("", &mock).await;
let result = call(
&client,
"update_bug_status",
json!({ "bug_id": 7, "status": "RESOLVED", "resolution": "FIXED" }),
)
.await;
assert!(!is_error(&result), "result: {}", text_of(&result));
assert_eq!(
sole_put_body(&mock).await,
json!({ "status": "RESOLVED", "resolution": "FIXED" })
);
}

#[tokio::test]
async fn update_bug_status_closed_without_resolution_reaches_upstream() {
// There is no local CLOSED pre-check any more: the request reaches
// Bugzilla, which is free to accept or reject it (missing_resolution).
let mock = MockServer::start().await;
mount_classify(&mock, world_readable_bug(7)).await;
mount_update_put(&mock, json!({ "status": "CLOSED" })).await;
let client = client_for("", &mock).await;
let result = call(
&client,
"update_bug_status",
json!({ "bug_id": 7, "status": "CLOSED" }),
)
.await;
assert!(!is_error(&result), "result: {}", text_of(&result));
assert_eq!(sole_put_body(&mock).await, json!({ "status": "CLOSED" }));
}

#[tokio::test]
async fn mark_as_duplicate_sends_only_dupe_of_and_comment() {
// No status/resolution insert: the instance's
// duplicate_or_move_bug_status decides, not this tool.
let mock = MockServer::start().await;
mount_classify(&mock, world_readable_bug(7)).await;
mount_classify(&mock, world_readable_bug(8)).await;
mount_update_put(
&mock,
json!({
"dupe_of": 8,
"comment": { "body": "Marking as duplicate of bug 8" },
}),
)
.await;
let client = client_for("", &mock).await;
let result = call(
&client,
"mark_as_duplicate",
json!({ "bug_id": 7, "duplicate_of": 8 }),
)
.await;
assert!(!is_error(&result), "result: {}", text_of(&result));
assert_eq!(
sole_put_body(&mock).await,
json!({
"dupe_of": 8,
"comment": { "body": "Marking as duplicate of bug 8" },
})
);
}

/// Tool names a client sees when it lists the server's tools — a real
/// `tools/list` request over the wire, so the handler's own listing path
/// is what answers.
Expand Down Expand Up @@ -1762,7 +1865,9 @@ async fn bug_fields_catalog_can_be_filtered_to_bug_entry_fields() {
}

#[tokio::test]
async fn bug_fields_detail_includes_legal_value_names() {
async fn bug_fields_detail_reports_workflow_data_when_upstream_carries_it() {
// bug_status carries is_open/can_change_to; a plain field's values stay
// {name}-only even when fetched through the same detail path.
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/field/bug/bug_status"))
Expand All @@ -1773,7 +1878,31 @@ async fn bug_fields_detail_includes_legal_value_names() {
"is_custom": false,
"is_mandatory": false,
"is_on_bug_entry": false,
"values": [{ "name": "NEW" }, { "name": "CONFIRMED" }],
"values": [
{
"name": "NEW",
"is_open": true,
"can_change_to": [
{ "name": "ASSIGNED", "comment_required": false },
{ "name": "RESOLVED", "comment_required": true },
],
},
{ "name": "RESOLVED", "is_open": false, "can_change_to": [] },
],
}]
})))
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/rest/field/bug/priority"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"fields": [{
"name": "priority",
"display_name": "Priority",
"is_custom": false,
"is_mandatory": false,
"is_on_bug_entry": false,
"values": [{ "name": "P1" }, { "name": "P2" }],
}]
})))
.mount(&mock)
Expand All @@ -1788,7 +1917,33 @@ async fn bug_fields_detail_includes_legal_value_names() {
.await;
assert!(!is_error(&result), "{}", text_of(&result));
let envelope: Value = serde_json::from_str(&text_of(&result)).expect("JSON");
assert_eq!(envelope["fields"][0]["values"], json!(["NEW", "CONFIRMED"]));
assert_eq!(
envelope["fields"][0]["values"],
json!([
{
"name": "NEW",
"is_open": true,
"can_change_to": [
{ "name": "ASSIGNED", "comment_required": false },
{ "name": "RESOLVED", "comment_required": true },
],
},
{ "name": "RESOLVED", "is_open": false, "can_change_to": [] },
])
);

let result = call(
&client,
"bug_fields",
json!({ "field_names": ["priority"] }),
)
.await;
assert!(!is_error(&result), "{}", text_of(&result));
let envelope: Value = serde_json::from_str(&text_of(&result)).expect("JSON");
assert_eq!(
envelope["fields"][0]["values"],
json!([{ "name": "P1" }, { "name": "P2" }])
);
}

#[tokio::test]
Expand Down
Loading