Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
12383a7
Add changeSummary API endpoint and UI components for description sour…
harshach Mar 17, 2026
f2b7b66
Fix checkstyle
harshach Mar 17, 2026
b77ce50
Integrate DescriptionSourceBadge into UI and address PR review comments
harshach Mar 21, 2026
3392ba8
Address PR review feedback: fix column FQN parsing, badge labels, bac…
harshach Mar 23, 2026
145d0b7
Improve change summary description layout
harshach Mar 23, 2026
a4aaabc
Expand change summary support across assets
harshach Mar 23, 2026
2511acc
Merge remote-tracking branch 'origin/main' into feature/change-summar…
pmbrull Mar 31, 2026
7db09ab
Fix changeSummary race condition and LLMModel entity type mismatch
pmbrull Apr 1, 2026
857a8ee
Fix Playwright strict mode violation and sync i18n translations
pmbrull Apr 1, 2026
2c4591e
fix
pmbrull Apr 2, 2026
71de2cb
Merge remote-tracking branch 'origin/main' into feature/change-summar…
pmbrull Apr 2, 2026
17a13a3
fix
pmbrull Apr 2, 2026
8d5e201
fix
pmbrull Apr 2, 2026
0f1f4ce
Merge branch 'main' into feature/change-summary-api
pmbrull Apr 2, 2026
1c4a1ea
implemented the new UI changes for AI description
Rohit0301 Apr 7, 2026
892495d
fixed the lint issues
Rohit0301 Apr 7, 2026
a18bfcb
addressed gitar comment
Rohit0301 Apr 7, 2026
48181a7
fixed the translations
Rohit0301 Apr 7, 2026
fd88967
fixed unit test
Rohit0301 Apr 7, 2026
b49dcdb
Merge branch 'main' into feature/change-summary-api
Rohit0301 Apr 8, 2026
bec52d6
addressed PR comment
Rohit0301 Apr 9, 2026
2da3ae0
fixed odcs playwright test
Rohit0301 Apr 9, 2026
532033e
Merge branch 'main' into feature/change-summary-api
harshach Apr 9, 2026
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties

.maestro
catalog-services/catalog-services.iml

# local docker volume
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6193,4 +6193,153 @@ void test_listEntityHistoryByTimestamp_completePaginationCycle(TestNamespace ns)
}
}
}

// ===================================================================
// CHANGE SUMMARY TESTS
// ===================================================================

/**
* Test: Retrieve changeSummary by entity ID after updating the entity.
* The changeSummary API returns metadata about who changed each field,
* the source of the change, and when it was changed.
*/
@Test
void get_changeSummaryById_200(TestNamespace ns) throws Exception {
K createRequest = createMinimalRequest(ns);
T created = createEntity(createRequest);

created.setDescription("Updated description for changeSummary test");
T updated = patchEntity(created.getId().toString(), created);

OpenMetadataClient client = SdkClients.adminClient();
String response =
client
.getHttpClient()
.executeForString(
HttpMethod.GET,
"/v1/changeSummary/" + getEntityType() + "/" + updated.getId(),
null);
assertNotNull(response, "ChangeSummary response should not be null");
JsonNode result = MAPPER.readTree(response);
assertTrue(result.has("changeSummary"), "Response must contain changeSummary field");
assertTrue(result.has("totalEntries"), "Response must contain totalEntries field");
}
Comment on lines +6270 to +6280
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assertions only check for presence of changeSummary/totalEntries, which will still pass when the API returns an empty map. To validate behavior, assert that changeSummary contains the updated field (e.g., description) and that totalEntries is > 0 after the PATCH.

Copilot uses AI. Check for mistakes.

/**
* Test: Retrieve changeSummary by entity FQN after updating the entity.
*/
@Test
void get_changeSummaryByFqn_200(TestNamespace ns) throws Exception {
K createRequest = createMinimalRequest(ns);
T created = createEntity(createRequest);

created.setDescription("Updated description for changeSummary FQN test");
T updated = patchEntity(created.getId().toString(), created);

OpenMetadataClient client = SdkClients.adminClient();
String fqn = updated.getFullyQualifiedName();
String response =
client
.getHttpClient()
.executeForString(
HttpMethod.GET, "/v1/changeSummary/" + getEntityType() + "/name/" + fqn, null);
assertNotNull(response, "ChangeSummary response should not be null");
JsonNode result = MAPPER.readTree(response);
assertTrue(result.has("changeSummary"), "Response must contain changeSummary field");
assertTrue(result.has("totalEntries"), "Response must contain totalEntries field");
}

/**
* Test: Retrieve changeSummary with fieldPrefix filter.
* Verifies that the filtering parameter works correctly.
*/
@Test
void get_changeSummaryWithFieldPrefix_200(TestNamespace ns) throws Exception {
K createRequest = createMinimalRequest(ns);
T created = createEntity(createRequest);

created.setDescription("Updated for fieldPrefix test");
T updated = patchEntity(created.getId().toString(), created);

OpenMetadataClient client = SdkClients.adminClient();
String response =
client
.getHttpClient()
.executeForString(
HttpMethod.GET,
"/v1/changeSummary/"
+ getEntityType()
+ "/"
+ updated.getId()
+ "?fieldPrefix=description",
null);
assertNotNull(response, "ChangeSummary filtered response should not be null");
JsonNode result = MAPPER.readTree(response);
assertTrue(result.has("changeSummary"), "Response must contain changeSummary field");
assertTrue(result.has("totalEntries"), "Response must contain totalEntries field");

JsonNode changeSummary = result.get("changeSummary");
if (changeSummary.isObject()) {
changeSummary
.fieldNames()
.forEachRemaining(
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn’t assert that any entries were returned for the fieldPrefix filter; if changeSummary is empty, the loop won’t run and the test will still pass. Add an assertion that at least one key exists and that returned keys match the requested prefix.

Copilot uses AI. Check for mistakes.
key ->
assertTrue(
key.startsWith("description"),
"All keys should start with 'description', but found: " + key));
}
}

/**
* Test: Retrieve changeSummary with pagination parameters.
*/
@Test
void get_changeSummaryWithPagination_200(TestNamespace ns) throws Exception {
K createRequest = createMinimalRequest(ns);
T created = createEntity(createRequest);

created.setDescription("Updated for pagination test");
patchEntity(created.getId().toString(), created);

OpenMetadataClient client = SdkClients.adminClient();
String response =
client
.getHttpClient()
.executeForString(
HttpMethod.GET,
"/v1/changeSummary/"
+ getEntityType()
+ "/"
+ created.getId()
+ "?limit=1&offset=0",
null);
assertNotNull(response, "ChangeSummary paginated response should not be null");
JsonNode result = MAPPER.readTree(response);
assertTrue(result.has("changeSummary"), "Response must contain changeSummary field");
assertTrue(result.has("totalEntries"), "Response must contain totalEntries field");
assertTrue(result.has("offset"), "Paginated response must contain offset field");
assertTrue(result.has("limit"), "Paginated response must contain limit field");
Comment thread
pmbrull marked this conversation as resolved.
}

/**
* Test: changeSummary returns 404 for non-existent entity.
*/
@Test
void get_changeSummaryNotFound_404(TestNamespace ns) {
OpenMetadataClient client = SdkClients.adminClient();
UUID randomId = UUID.randomUUID();
Exception thrown =
assertThrows(
Exception.class,
() ->
client
.getHttpClient()
.executeForString(
HttpMethod.GET,
"/v1/changeSummary/" + getEntityType() + "/" + randomId,
null));
assertTrue(
thrown.getMessage().contains("404") || thrown.getMessage().contains("not found"),
"Should get 404 for non-existent entity, got: " + thrown.getMessage());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/*
* Copyright 2021 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.openmetadata.service.resources;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.SecurityContext;
import jakarta.ws.rs.core.UriInfo;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.EntityInterface;
import org.openmetadata.schema.type.ChangeDescription;
import org.openmetadata.schema.type.ChangeSummaryMap;
import org.openmetadata.schema.type.MetadataOperation;
import org.openmetadata.schema.type.change.ChangeSummary;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.EntityRepository;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.security.policyevaluator.ResourceContext;

@Slf4j
@Path("/v1/changeSummary")
@Tag(
name = "ChangeSummary",
description =
"APIs to retrieve change summary metadata for entities. "
+ "Change summary tracks who changed each field, the source of the change "
+ "(e.g., Suggested for AI-generated, Manual for user edits), and when the change occurred.")
@Produces(MediaType.APPLICATION_JSON)
@Collection(name = "changeSummary")
public class ChangeSummaryResource {

private final Authorizer authorizer;

public ChangeSummaryResource(Authorizer authorizer) {
this.authorizer = authorizer;
}

@GET
@Path("/{entityType}/{id}")
@Operation(
operationId = "getChangeSummaryById",
summary = "Get change summary for an entity by ID",
description =
"Returns the change summary map for the specified entity, showing who changed each field, "
+ "the source of the change (Manual, Suggested, Automated, etc.), and when it was changed. "
+ "Use fieldPrefix to filter entries (e.g., 'columns.' for column-level changes only).",
responses = {
@ApiResponse(responseCode = "200", description = "Change summary map"),
@ApiResponse(responseCode = "404", description = "Entity not found")
})
public Response getChangeSummaryById(
Comment thread
gitar-bot[bot] marked this conversation as resolved.
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Parameter(
description = "Entity type (e.g., table, topic, dashboard)",
schema = @Schema(type = "string"))
@PathParam("entityType")
String entityType,
@Parameter(description = "Entity ID", schema = @Schema(type = "UUID")) @PathParam("id")
UUID id,
@Parameter(
description =
"Filter entries by field name prefix (e.g., 'columns.' to get only column-level changes)",
schema = @Schema(type = "string"))
@QueryParam("fieldPrefix")
String fieldPrefix,
@Parameter(
description = "Limit the number of entries returned",
schema = @Schema(type = "integer"))
@QueryParam("limit")
@DefaultValue("10")
int limit,
Comment on lines +96 to +103
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

limit is unbounded and not validated (unlike other list-style endpoints that use @Min/@max). This allows negative/very large values that can lead to empty pages, unexpected pagination behavior, or oversized responses. Add bean validation annotations (e.g., @min(1) and a reasonable @max) and consider also validating offset (>= 0).

Copilot uses AI. Check for mistakes.
@Parameter(description = "Offset for pagination", schema = @Schema(type = "integer"))
@QueryParam("offset")
@DefaultValue("0")
int offset) {
Comment on lines +104 to +108
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

offset is not validated (e.g., negative values). With the current pagination loop, a negative offset will effectively behave like 0 but is still an invalid request and can mask client bugs. Add bean validation (e.g., @min(0)).

Copilot uses AI. Check for mistakes.

OperationContext operationContext =
new OperationContext(entityType, MetadataOperation.VIEW_BASIC);
ResourceContext<?> resourceContext = new ResourceContext<>(entityType, id, null);
authorizer.authorize(securityContext, operationContext, resourceContext);

EntityRepository<?> repository = Entity.getEntityRepository(entityType);
EntityInterface entity = (EntityInterface) repository.get(null, id, repository.getFields("*"));
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint fetches the full entity using getFields("*") just to read changeDescription.changeSummary. For large entities (e.g., tables with many columns), this is unnecessarily expensive. Fetch only the minimal required fields (e.g., changeDescription) to reduce DB/JSON payload and server-side processing time.

Suggested change
EntityInterface entity = (EntityInterface) repository.get(null, id, repository.getFields("*"));
EntityInterface entity =
(EntityInterface) repository.get(null, id, repository.getFields("changeDescription"));

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uriInfo is available but the repository call passes null instead. Either remove the unused @Context UriInfo parameter or pass uriInfo into repository.get(...) for consistency with other resources and to avoid missing/incorrect href values on the loaded entity.

Suggested change
EntityInterface entity = (EntityInterface) repository.get(null, id, repository.getFields("*"));
EntityInterface entity =
(EntityInterface) repository.get(uriInfo, id, repository.getFields("*"));

Copilot uses AI. Check for mistakes.

return buildResponse(entity, fieldPrefix, limit, offset);
}

@GET
@Path("/{entityType}/name/{fqn}")
@Operation(
operationId = "getChangeSummaryByFqn",
summary = "Get change summary for an entity by fully qualified name",
description =
"Returns the change summary map for the specified entity identified by its "
+ "fully qualified name (FQN). Use fieldPrefix to filter entries "
+ "(e.g., 'columns.' for column-level changes only).",
responses = {
@ApiResponse(responseCode = "200", description = "Change summary map"),
@ApiResponse(responseCode = "404", description = "Entity not found")
})
public Response getChangeSummaryByFqn(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Parameter(
description = "Entity type (e.g., table, topic, dashboard)",
schema = @Schema(type = "string"))
@PathParam("entityType")
String entityType,
@Parameter(
description = "Fully qualified name of the entity",
schema = @Schema(type = "string"))
@PathParam("fqn")
String fqn,
@Parameter(
description =
"Filter entries by field name prefix (e.g., 'columns.' to get only column-level changes)",
schema = @Schema(type = "string"))
@QueryParam("fieldPrefix")
String fieldPrefix,
@Parameter(
description = "Limit the number of entries returned",
schema = @Schema(type = "integer"))
@QueryParam("limit")
@DefaultValue("10")
int limit,
@Parameter(description = "Offset for pagination", schema = @Schema(type = "integer"))
@QueryParam("offset")
@DefaultValue("0")
int offset) {

OperationContext operationContext =
new OperationContext(entityType, MetadataOperation.VIEW_BASIC);
ResourceContext<?> resourceContext = new ResourceContext<>(entityType, null, fqn);
authorizer.authorize(securityContext, operationContext, resourceContext);

EntityRepository<?> repository = Entity.getEntityRepository(entityType);
EntityInterface entity =
(EntityInterface) repository.getByName(null, fqn, repository.getFields("*"));
Comment thread
pmbrull marked this conversation as resolved.
Outdated

return buildResponse(entity, fieldPrefix, limit, offset);
}

private Response buildResponse(
EntityInterface entity, String fieldPrefix, int limit, int offset) {
ChangeDescription changeDescription = entity.getChangeDescription();
ChangeSummaryMap changeSummaryMap =
changeDescription != null ? changeDescription.getChangeSummary() : null;
Map<String, ChangeSummary> changeSummary =
changeSummaryMap != null ? changeSummaryMap.getAdditionalProperties() : null;

if (changeSummary == null || changeSummary.isEmpty()) {
return Response.ok(Map.of("changeSummary", Map.of(), "totalEntries", 0)).build();
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The empty changeSummary response omits offset and limit, while the non-empty response always includes them. For a paginated endpoint this inconsistency makes client handling more complex. Consider always returning offset/limit (even when totalEntries=0) to keep the response shape stable.

Suggested change
return Response.ok(Map.of("changeSummary", Map.of(), "totalEntries", 0)).build();
return Response.ok(
Map.of(
"changeSummary", Map.of(),
"totalEntries", 0,
"offset", offset,
"limit", limit))
.build();

Copilot uses AI. Check for mistakes.
}

// Apply field prefix filter
Map<String, ChangeSummary> filtered;
if (fieldPrefix != null && !fieldPrefix.isEmpty()) {
filtered = new LinkedHashMap<>();
for (Map.Entry<String, ChangeSummary> entry : changeSummary.entrySet()) {
if (entry.getKey().startsWith(fieldPrefix)) {
filtered.put(entry.getKey(), entry.getValue());
}
}
} else {
filtered = changeSummary;
}

// Apply pagination
Map<String, ChangeSummary> paginated = new LinkedHashMap<>();
int count = 0;
int added = 0;
for (Map.Entry<String, ChangeSummary> entry : filtered.entrySet()) {
if (count >= offset) {
if (added >= limit) {
break;
}
paginated.put(entry.getKey(), entry.getValue());
added++;
}
count++;
}
Comment on lines +193 to +219
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pagination is applied by iterating over filtered.entrySet(), but the underlying changeSummary map order is not guaranteed to be stable (depends on JSON deserialization / map implementation). This can make limit/offset pagination non-deterministic across requests. Consider applying a deterministic ordering (e.g., sort by key, or by changedAt desc then key) before slicing.

Copilot uses AI. Check for mistakes.
return Response.ok(
Map.of(
"changeSummary", paginated,
"totalEntries", filtered.size(),
"offset", offset,
"limit", limit))
.build();
}
}
Loading
Loading