diff --git a/crates/worker/src/feature/builtin/ticket/results.rs b/crates/worker/src/feature/builtin/ticket/results.rs index 13072cd84..d30fd8e1c 100644 --- a/crates/worker/src/feature/builtin/ticket/results.rs +++ b/crates/worker/src/feature/builtin/ticket/results.rs @@ -249,6 +249,15 @@ impl References { add(&mut refs.tickets, row, "ticket"); add(&mut refs.mrs, row, "merge_request_id"); } + // MR origin checks are independent of capture and of the selected item page. + // Other check kinds identify Jobs/events, not published MR Objects. + for check in array(data, "source_checks") { + if check.get("resource_kind").and_then(Value::as_str) + == Some("merge_request_events") + { + add(&mut refs.mrs, check, "reference"); + } + } } _ => {} // Tag/answer/tracking-history/delivery identifiers and prose are domain data. } diff --git a/crates/worker/src/feature/builtin/ticket/results/tests.rs b/crates/worker/src/feature/builtin/ticket/results/tests.rs index 43da45f44..88333bad4 100644 --- a/crates/worker/src/feature/builtin/ticket/results/tests.rs +++ b/crates/worker/src/feature/builtin/ticket/results/tests.rs @@ -303,3 +303,44 @@ fn assignment_event_and_tag_identifiers_do_not_create_child_objects_or_text_link ); } } + +#[test] +fn diagnostics_mr_origin_references_are_independent_of_capture_and_item_page() { + for items in [ + json!([]), + json!([{"ticket":"T-42","merge_request_id":null}]), + ] { + for root in ["", "/changes"] { + let data = json!({"items":items,"next_cursor":"opaque-MR-999", + "source_checks":[ + {"resource_kind":"merge_request_events","reference":"MR-1","origin_present":true,"captured":false}, + {"resource_kind":"job_result","reference":"MR-2","origin_present":true,"captured":true}, + {"resource_kind":"source_event","reference":"mr-event:MR-3:event","origin_present":true,"captured":true}, + {"resource_kind":"future_kind","reference":"MR-4","origin_present":true,"captured":false} + ]}); + let output = projected("notification_diagnostics", data.clone(), root, ""); + assert_eq!(output["data"], data); + let expected = if root.is_empty() { + json!([{"id":"MR-1"}]) + } else { + json!([{"id":"MR-1","entry":"/changes/MR-1"}]) + }; + assert_eq!(output["links"]["merge_requests"], expected); + assert!(output.get("entry").is_none()); + let descriptor = InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: None, + types: vec![], + operations: vec![OperationDeclaration { + name: "notification_diagnostics".into(), + documentation: None, + parameters: vec![], + returns: declaration("notification_diagnostics", false).unwrap(), + }], + }; + descriptor + .validate_result("notification_diagnostics", &json_to_wip(&output).unwrap()) + .unwrap(); + } + } +} diff --git a/crates/worker/src/wip/ticket_native_tests.rs b/crates/worker/src/wip/ticket_native_tests.rs index ace7967de..bd76f8c2c 100644 --- a/crates/worker/src/wip/ticket_native_tests.rs +++ b/crates/worker/src/wip/ticket_native_tests.rs @@ -1169,3 +1169,113 @@ async fn retained_ticket_result_mr_reference_is_model_navigation_not_body_or_sna assert_eq!(client.requests().len(), 2); client.assert_authorizations_consumed(); } + +#[tokio::test] +async fn diagnostics_uncaptured_mr_origin_is_typed_lazy_model_navigation_even_on_empty_pages() { + for published in [false, true] { + let page = json!({"items":[],"next_cursor":"opaque-next", + "source_checks":[{"resource_kind":"merge_request_events","reference":"MR-1","origin_present":true,"captured":false}]}); + let mut responses = vec![crate::worker::WorkspaceResponse { + status: 200, + body: page.to_string(), + }]; + let mut authorizations = vec![expected_authorization( + "notification.diagnostics.read", + "merge_request", + Some(("merge_request_id", "MR-1")), + )]; + if published { + responses.push(crate::worker::WorkspaceResponse { + status: 200, + body: json!({"merge_request_id":"MR-1","ticket_ids":[],"thread":[]}).to_string(), + }); + authorizations.push(expected_authorization( + "merge_request.read", + "merge_request", + Some(("merge_request_id", "MR-1")), + )); + } + let client = + Arc::new(ScriptedWorkspaceClient::new(responses).with_authorizations(authorizations)); + let mut registry = WipMountRegistry::new(); + let tickets = registry.allocate_namespace("ticket", "cases").unwrap(); + let changes = registry + .allocate_namespace("merge-request", "changes") + .unwrap(); + crate::feature::builtin::ticket::mount_workspace_http_ticket_wip( + &mut registry, + client.clone(), + crate::feature::builtin::ticket::TicketFeatureAccess::read_only(), + None, + &tickets, + ) + .unwrap(); + if published { + crate::feature::builtin::merge_request::mount_workspace_http_merge_request_wip( + &mut registry, + client.clone(), + manifest::MergeRequestFeatureConfig { + show: true, + ..Default::default() + }, + None, + &changes, + ) + .unwrap(); + } + let mut engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient); + let runtime = install_wip_mode_with_mounts( + &mut engine, + None, + "diagnostics-origin-model".into(), + registry, + ) + .unwrap(); + let tools = engine.tool_server_handle(); + tools.flush_pending(); + let collection = ticket_result_model_inspect(&tools, &json!("/cases")).await; + let result=ticket_result_model_invoke(&tools,&collection,"notification_diagnostics",json!({"filter":{"merge_request":"/changes/MR-1"},"page":{"cursor":"previous-page","limit":1}})).await; + assert_eq!(result["data"], page); + assert_eq!(result["links"]["merge_requests"][0]["id"], "MR-1"); + assert_eq!( + client.requests().len(), + 1, + "origin reference never prefetches current MR" + ); + let declaration = runtime + .host + .projection("/cases") + .unwrap() + .descriptor + .operations + .into_iter() + .find(|op| op.name == "notification_diagnostics") + .unwrap(); + let mut entries = vec![]; + ticket_result_declared_entries(&declaration.returns.r#type, &result, &mut entries); + if published { + assert_eq!(entries, vec!["/changes/MR-1"]); + let mr = + ticket_result_model_inspect(&tools, &result["links"]["merge_requests"][0]["entry"]) + .await; + let interface = ticket_result_model_inspect(&tools, &mr["interfaces"][0]["path"]).await; + assert!( + interface["interface_signature"] + .as_str() + .unwrap() + .contains("read(") + ); + ticket_result_model_invoke(&tools, &mr, "read", json!({})).await; + assert_eq!(client.requests().len(), 2); + } else { + assert!(entries.is_empty()); + assert!(result["links"]["merge_requests"][0].get("entry").is_none()); + } + client.assert_authorizations_consumed(); + let request = &client.requests()[0]; + assert!(request.path.contains("cursor=previous-page")); + assert!(request.path.contains("merge_request_id=MR-1")); + let tree = ticket_result_model_call(&tools, "Tree", json!({"path":"/","depth":3})).await; + assert!(!tree.to_string().contains("/changes/MR-1")); + } +}