From 2160a7493f39842f1157f32e0be6613cc824cbd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20S=C3=BAkup?= Date: Sun, 9 Aug 2026 13:03:56 +0200 Subject: [PATCH 1/2] fix(server): stop imposing one instance's status workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update_bug_status hard-coded a workflow no Bugzilla instance is required to have: a local CLOSED/resolution pre-check that missed RESOLVED and any custom closed status, and a synthesised "resolution": "" on every non-CLOSED/VERIFIED target that Bugzilla's _check_resolution actually rejects (missing_resolution) on a stock RESOLVED transition. mark_as_duplicate had the same assumption, hard-coding status=CLOSED alongside dupe_of. Bug.pm's set_bug_status already clears the resolution itself when the target status is open, and set_dup_id already applies the instance's duplicate_or_move_bug_status when dupe_of is set alone — so both local guesses were dead weight on stock behavior and wrong on customized workflows. Drop them: update_bug_status sends resolution only when the caller gives a non-empty one, and mark_as_duplicate sends dupe_of (and the comment) only, letting Bugzilla decide the resulting status. Accepted behavior change: mark_as_duplicate now lands a bug in whatever status the instance's duplicate_or_move_bug_status names (RESOLVED/DUPLICATE on stock Bugzilla) rather than always CLOSED. --- README.md | 4 +- crates/bugwarden/src/server.rs | 26 ++---- crates/bugwarden/tests/tools_wiremock.rs | 103 +++++++++++++++++++++++ docs/DESIGN.md | 4 +- 4 files changed, 116 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 9495b8e..00aa14b 100644 --- a/README.md +++ b/README.md @@ -677,12 +677,12 @@ 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) | diff --git a/crates/bugwarden/src/server.rs b/crates/bugwarden/src/server.rs index 8c775a3..789082c 100644 --- a/crates/bugwarden/src/server.rs +++ b/crates/bugwarden/src/server.rs @@ -1012,7 +1012,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, /// Optional comment explaining the change. @@ -2198,7 +2200,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, @@ -2217,13 +2219,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 @@ -2235,11 +2230,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); @@ -2572,7 +2564,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, @@ -2630,9 +2622,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); diff --git a/crates/bugwarden/tests/tools_wiremock.rs b/crates/bugwarden/tests/tools_wiremock.rs index 0dc8009..5654dc5 100644 --- a/crates/bugwarden/tests/tools_wiremock.rs +++ b/crates/bugwarden/tests/tools_wiremock.rs @@ -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. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 24ea078..e3e83fe 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -872,12 +872,12 @@ constraints the model must know. | create_bug | product, component, summary, version, description = "", severity?, priority?, op_sys?, platform?, keywords?: Vec, groups?: Vec, custom_fields?: JsonObject | create (write), judged on the bug AS REQUESTED (Guard::may_create) | there is no bug id to assess, so the request itself is classified BEFORE any upstream call (I8): the rules that hide a product by name refuse filing into it, a field the request omits fails closed (I4), and a client-claimed `groups` list is never trusted — Bugzilla unions the product's mandatory groups in server-side, so may_create forces groups to unknown, which means a group-consulting rule refuses every create request that REACHES it — creation is possible only where an earlier rule covering the create operation grants it (a rule carrying `operations = ["create"]`, placed ahead of the group-consulting rules, is how an operator permits filing without that grant shadowing reads of existing bugs — issue #26), and a policy with no such grant refuses all creation. **Both refusals are one refusal**: a policy refusal and an upstream failure return the same fixed create_denial text after the same single upstream request — the refused path burns one classify call against bug id 0 (never a valid id, creates nothing; download_attachment's padding precedent) instead of the POST. Two texts, or 0 vs 1 requests, would be a free policy-enumeration oracle: send a guaranteed-invalid `version` plus a probe product and read the policy off which refusal (or which latency) comes back, with nothing created. Residual, accepted: a SUCCESSFUL create still confirms the product is allowed — that is the tool doing its job, and it costs a real, attributable bug; and the padding equalizes request count, not the upstream handler's exact latency (GET classify vs rejected POST). Bugzilla's failure message is logged server-side only (it can say whether a product/component exists). `custom_fields` keys must start with `cf_` (I7): the gate runs before `may_create` and errors with ZERO upstream requests on a non-`cf_` key — distinguishable from the padded create refusal on purpose, since it decides nothing about policy or Bugzilla. No Matcher criterion reads `cf_*`, so a custom field cannot move a prospective bug between rules the way `product`/`component` do. `custom_fields` is not in `PARAM_ALLOWLIST`, so the audit stream records it as `_len`, same as the updater | | add_attachment | bug_id, data (base64), file_name, summary, content_type, comment = "", is_private = false, is_patch = false | attach (write) on bug_id | guard assessment before the upload (I8), uniform denial (I2); then global.max_attachment_bytes caps the DECODED size of `data` (0 = no cap) — the ceiling the operator set on downloads binds uploads through the same server too, measured after base64 expansion is stripped so encoding overhead cannot shrink it. The refusal names neither the payload's size nor the cap value (max_attachment_bytes is not I1-disclosable, exactly as on the download path). Over http that non-disclosure is partial and knowingly so: the transport's POST body cap is derived from this same value (#52), so its 413 boundary is probeable once the cap exceeds ~2.25 MiB decoded — accepted, with the reasoning, under "rmcp 3.1 usage notes" below. Nothing here changes: this refusal still names neither size nor cap. `comment` travels as a PLAIN string — Bug.add_attachment documents it so; the `{"comment": {"body": ..}}` shape belongs to Bug.update only | | add_comment | bug_id, comment, is_private: bool = false | comment (write) | | -| update_bug_status | bug_id, status, resolution?, comment: String = "" | status (write) | CLOSED requires resolution (error otherwise); when reopening (status not CLOSED/VERIFIED and no resolution given) set `"resolution": ""` | +| update_bug_status | bug_id, status, resolution?, comment: String = "" | status (write) | payload always carries `status`; `resolution` only when the caller gives a non-empty one — no local workflow assumption, no synthesised empty resolution. Bugzilla enforces `missing_resolution` on a closing status with none, and auto-clears any resolution when the target status is open | | assign_bug | bug_id, assignee (email), comment = "" | assign (write) | payload `{"assigned_to": ..}` | | update_bug_fields | bug_id, priority?, severity?, resolution?, summary?, url?, whiteboard?, version?, target_milestone?, keywords_add?/keywords_remove?: Vec, see_also_add?/see_also_remove?: Vec (bug URLs), custom_fields?: JsonObject, comment = "" | fields (write) on bug_id + summary on every LOCAL see_also target (I8/I14) | at least one field required — the named params all count, so a call touching only the newer fields is valid, and a call carrying nothing but empty strings/lists still errors without contacting Bugzilla; empty strings and empty lists are ignored (clearing a field is unsupported); keywords and see_also travel as `{"add": [..], "remove": [..]}`, NEVER the replace-all `set` variant; a see_also entry that points at THIS instance is a bug-id link, so its target is assessed like a dependency target — at least `summary`, uniform denial (I2), no PUT on refusal — while entries for other trackers carry no local id and pass through unassessed; custom_fields keys must start with `cf_` (I7) — `see_also` and `keywords` are named params now, and as custom_fields keys they still error before Bugzilla is contacted; free-text values (summary/whiteboard/url) never enter the server log — only which fields a call touched (see "Update-field surface") | | update_bug_dependencies | bug_id, blocks_add?/blocks_remove?/depends_on_add?/depends_on_remove?: Vec, comment = "" | deps (write) | at least one change required; payload uses `{"blocks": {"add": [..], "remove": [..]}}` shape | | add_cc_to_bug | bug_id, cc_email | cc (write) | payload `{"cc": {"add": [email]}}` | -| mark_as_duplicate | bug_id, duplicate_of, comment = "" | status on bug_id + summary on duplicate_of (I11) | default comment "Marking as duplicate of bug {duplicate_of}"; payload status CLOSED, resolution DUPLICATE, dupe_of | +| mark_as_duplicate | bug_id, duplicate_of, comment = "" | status on bug_id + summary on duplicate_of (I11) | default comment "Marking as duplicate of bug {duplicate_of}"; payload carries only `dupe_of` (+ comment) — Bugzilla's `set_dup_id` applies the instance's `duplicate_or_move_bug_status` and resolution DUPLICATE itself, so the resulting status is instance-defined, not necessarily CLOSED | | list_attachments | bug_id | attachments | metadata only (`exclude_fields=data`) | | download_attachment | attachment_id, include_private: bool = false | attachments (on the owning bug) | metadata fetched FIRST (no blob) for guard assessment + attachment_gate; unknown id, metadata OR blob fetch failure, denied owning bug, missing bug_id, and private-without-opt-in all yield the uniform attachment denial. Constant upstream request count on every path (a metadata miss still runs one classify call against bug id 0) so call latency is not an existence oracle. The gate AND the bug-id check re-run on the blob response (TOCTOU), then the actual base64 size is re-checked against the cap (a lying `size` cannot bypass it). Raster image types from a strict allowlist => ContentBlock::image; everything else (incl. image/svg+xml) => BlobResourceContents whose uri carries only the attachment id (uploader-chosen file_name never enters the uri) | | bug_url | bug_id | none (I8 exception) | `{base_url}/show_bug.cgi?id={id}` | From 2563f891b7ac2531c440056e2807e45437f403e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20S=C3=BAkup?= Date: Sun, 9 Aug 2026 13:06:20 +0200 Subject: [PATCH 2/2] feat(server): report the status workflow in bug_fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bug_fields' detail projection reduced legal values to bare names, dropping the is_open and can_change_to data Bugzilla attaches to bug_status specifically — exactly what a client would need to learn an instance's status workflow instead of guessing it, which is what update_bug_status and mark_as_duplicate now defer to Bugzilla for. Project each legal value to {name}, plus is_open and can_change_to (itself projected to [{name, comment_required}]) when the upstream value carries them. Absent keys are omitted, never null, so every non-workflow field's values stay exactly as cheap as before. The catalog shape (no field_names) is untouched, and the data is reported exactly as Bugzilla gave it — I16's guard-policy exemption already covers it. --- README.md | 2 +- crates/bugwarden/src/server.rs | 35 +++++++++++++- crates/bugwarden/tests/tools_wiremock.rs | 58 ++++++++++++++++++++++-- docs/DESIGN.md | 2 +- 4 files changed, 90 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 00aa14b..9ada427 100644 --- a/README.md +++ b/README.md @@ -688,7 +688,7 @@ none of the three needs an API key. Every other tool does, including | `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 | diff --git a/crates/bugwarden/src/server.rs b/crates/bugwarden/src/server.rs index 789082c..aa3dc20 100644 --- a/crates/bugwarden/src/server.rs +++ b/crates/bugwarden/src/server.rs @@ -726,15 +726,46 @@ fn project_field_catalog(envelope: &Value, on_bug_entry_only: bool) -> Vec Option { + 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 { + 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 = 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 = 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)); diff --git a/crates/bugwarden/tests/tools_wiremock.rs b/crates/bugwarden/tests/tools_wiremock.rs index 5654dc5..4d1834f 100644 --- a/crates/bugwarden/tests/tools_wiremock.rs +++ b/crates/bugwarden/tests/tools_wiremock.rs @@ -1865,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")) @@ -1876,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) @@ -1891,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] diff --git a/docs/DESIGN.md b/docs/DESIGN.md index e3e83fe..8858e17 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -883,7 +883,7 @@ constraints the model must know. | bug_url | bug_id | none (I8 exception) | `{base_url}/show_bug.cgi?id={id}` | | bugzilla_server_info | — | none | client.server_info | | bugzilla_products | products?: Vec (max 5) | none — present only when `global.allow_discovery = true` (I16) | no `products` named: `enterable_product_ids` then `products(ids, [], [id,name])`, projected to `{id, name}` catalog entries; `products` named: `products([], names, None)`, projected to `{name, description, is_active, default_milestone, has_unconfirmed, components[{name,description,is_active}], versions[{name,is_active}], milestones[{name,is_active}]}` — `default_assigned_to`/`default_qa_contact` are never selected. Over-cap (>5 names) refuses with a fixed text and makes ZERO upstream requests, since the refusal is a pure function of the request's own shape | -| bug_fields | field_names?: Vec (max 5), on_bug_entry_only: bool = false | none — present only when `global.allow_discovery = true` (I16) | no `field_names`: `bug_fields(None)`, projected per field to `{name, display_name, type, is_custom, is_mandatory, is_on_bug_entry, visibility_field, visibility_values, has_values}` — NEVER `values` — optionally filtered to `is_on_bug_entry` fields; `field_names` named: one `bug_fields(Some(name))` call per name (sequential; Bugzilla's field lookup is single-field), same projection plus `values` reduced to legal-value NAMES only. Over-cap (>5 names) refuses with a fixed text and makes ZERO upstream requests. A named field Bugzilla does not recognise is a call-level failure (the generic `Failed to fetch bug fields` text), not a partial result | +| bug_fields | field_names?: Vec (max 5), on_bug_entry_only: bool = false | none — present only when `global.allow_discovery = true` (I16) | no `field_names`: `bug_fields(None)`, projected per field to `{name, display_name, type, is_custom, is_mandatory, is_on_bug_entry, visibility_field, visibility_values, has_values}` — NEVER `values` — optionally filtered to `is_on_bug_entry` fields; `field_names` named: one `bug_fields(Some(name))` call per name (sequential; Bugzilla's field lookup is single-field), same projection plus `values` as `[{name, is_open?, can_change_to?}]` — `is_open` and `can_change_to: [{name, comment_required}]` present exactly when the upstream value carries them (only `bug_status` does today), omitted rather than `null` on every other field, reported exactly as Bugzilla gave it (I16). Over-cap (>5 names) refuses with a fixed text and makes ZERO upstream requests. A named field Bugzilla does not recognise is a call-level failure (the generic `Failed to fetch bug fields` text), not a partial result | | quicksearch_syntax | — | none | HTML doc page | | mcp_server_info | — | none | name (CARGO_PKG_NAME) and version (CARGO_PKG_VERSION), the same two the handshake sends; bugzilla server url, transport, and policy summary per I1 | | summarize_bug | id | comments | fetches comments (private filtered with include_private=false), returns the summarization prompt text (fixed prompt template) |