Skip to content

feat: show proposed changes with clickable entity links in approval tasks#27201

Merged
yan-3005 merged 23 commits intomainfrom
ram/approval-task-proposed-changes
Apr 17, 2026
Merged

feat: show proposed changes with clickable entity links in approval tasks#27201
yan-3005 merged 23 commits intomainfrom
ram/approval-task-proposed-changes

Conversation

@yan-3005
Copy link
Copy Markdown
Contributor

@yan-3005 yan-3005 commented Apr 9, 2026

Summary

Approval Task Image:
image

Fixes #27440

Governance approval task threads now include a Proposed Changes section showing exactly what changed on the entity before the approver acts.


Why not use feedInfo.entitySpecificInfo (rich card approach)?

The activity-feed formatter pipeline already produces per-field rich card data via feedInfo.entitySpecificInfo — but entitySpecificInfo is a single polymorphic object, not an array. When multiple fields change in one approval (e.g. tags + description), the formatter returns a List<Thread> with one entry per field, each carrying its own entitySpecificInfo. There is only one feedInfo slot on the task thread, so only one field's data can be stored — the rest are lost.

Fixing this properly would require turning feedInfo.entitySpecificInfo into an array in the spec, plus a data migration over every existing Thread row in the feed table (potentially millions of records in production). A Task Redesign is landing in ~2 months, so we are not introducing schema changes or data migrations for this.


Where the data lives in the Thread JSON

The message field is a top-level field on the Thread object returned by GET /api/v1/feed?entityLink=...&type=Task. The backend writes a structured JSON object into this field. cardStyle, fieldOperation, and feedInfo are explicitly set to null for approval tasks (preventing stale rich-card data).

{
  "id": "5a00a510-...",
  "type": "Task",
  "about": "<#E::glossaryTerm::1.2>",

  "message": "{\"tags\":{\"added\":[\"PII.Sensitive\"],\"removed\":[\"PII.None\"]},\"description\":{\"added\":[\"<p>new text</p>\"],\"removed\":[\"<p>old text</p>\"]}}",

  "cardStyle": null,
  "fieldOperation": null,
  "feedInfo": null,

  "task": {
    "id": 22,
    "type": "RequestApproval",
    "assignees": [ { "name": "aaron.singh2" } ],
    "status": "Open"
  }
}

The JSON structure inside message:

{
  "tags": {
    "added": ["PII.Sensitive", "PersonalData.Personal"],
    "removed": ["PII.None"]
  },
  "description": {
    "added": ["<p>new description</p>"],
    "removed": ["<p>old description</p>"]
  },
  "owners": {
    "added": ["Aaron Johnson"],
    "removed": ["Jane Smith"]
  }
}

Identifier extraction priority for each field value:

  1. tagFQN — for tag labels
  2. fullyQualifiedName — for domains, glossary terms, entities
  3. displayName — for users, teams, entity references
  4. name — fallback for named objects
  5. Raw string — for scalar fields like description, entityStatus

No new JSON schema fields were added to Thread, TaskDetails, or FeedInfo. The existing thread.message field carries this payload.


Merge across re-edits (set-cancellation)

When an entity is edited while its approval task is still open, the changes are merged — not overwritten. The algorithm uses set-cancellation so the approver always sees the net difference from the original state:

mergedAdded   = (oldAdded − newRemoved) ∪ (newAdded − oldRemoved)
mergedRemoved = (oldRemoved − newAdded) ∪ (newRemoved − oldAdded)

Example across 3 edits on a glossary term:

Edit Change Accumulated preview
1 tag: PII.NonePII.Sensitive tags: {added: [PII.Sensitive], removed: [PII.None]}
2 add PersonalData.Personal tags: {added: [PII.Sensitive, PersonalData.Personal], removed: [PII.None]}
3 remove PII.Sensitive tags: {added: [PersonalData.Personal], removed: [PII.None]}

Fields where both added and removed become empty after cancellation are dropped entirely.


Backend changes — CreateApprovalTaskImpl.java

All change-preview logic extracted into org.openmetadata.service.governance.workflows.util.ChangePreviewUtils:

  • buildChangeMap(ChangeDescription) — converts fieldsAdded/Updated/Deleted to the JSON map
  • mergeChangeMaps(oldMap, newMap) — set-cancellation merge across re-edits
  • parseChangeMap(String message) — deserializes existing JSON message for merging
  • extractIdentifiers(Object fieldValue) — handles JSON arrays, objects, and plain strings
  • applyChangePreview(Thread, EntityInterface, String oldMessage) — orchestrates the full preview update; clears cardStyle/fieldOperation/feedInfo to null

CreateApprovalTaskImpl is now pure Flowable orchestration — calls ChangePreviewUtils.applyChangePreview(...) on both the create and update paths.


UI changes — TaskTabNew.component.tsx

The Proposed Changes card is rendered immediately after taskHeader. It is shown when taskThread.message?.trimStart().startsWith('{') (JSON detection).

Each field renders as a row: field name label + red chips for removed values + green chips for added values. Fields in FIELD_ROUTE_MAP (tags, relatedTerms, domains) render as clickable links to the relevant entity page.

Removed dangerouslySetInnerHTML, FIELD_LINK_MAP, DIFF_SPAN_RE, escapeHtml, addEntityLinks, and the markdown/HTML rendering pipeline entirely.

Notification panel (NotificationFeedCard.component.tsx): replaced {task.message} (which leaked raw HTML/JSON into the bell notification) with the static i18n string "Approval request for".


Test plan

  • Create a glossary term with approval workflow enabled → add one tag → open approval task → "Proposed Changes" shows the tag with a red chip for removed and green chip for added, clickable link to the tag page
  • Update description AND add a tag in one PATCH → both fields appear as separate rows
  • Re-edit the term while approval is still open → task refreshes with merged changes across all edits (exercises set-cancellation)
  • Re-add a previously removed tag → it cancels out from the removed list (net zero, disappears from preview)
  • Create a brand new term (no prior version, no changeDescription) → "Proposed Changes" section is absent
  • Verify no regressions on non-approval task types (RequestDescription, RequestTag, etc.)
  • Bell notification for approval task shows "Approval request for [entity]" — no raw HTML or JSON

…asks

Governance approval tasks now display a "Proposed Changes" preview so approvers can see exactly what changed before approving. Backend runs the entity's changeDescription through the activity-feed formatter pipeline and stores a markdown bullet list in thread.message. UI renders the preview using Showdown + dangerouslySetInnerHTML and injects clickable hyperlinks for tags, tier, glossary terms, related terms, domains, owners, reviewers, and experts directly in the frontend before conversion.
@yan-3005 yan-3005 requested a review from a team as a code owner April 9, 2026 09:32
@yan-3005 yan-3005 added the UI UI specific issues label Apr 9, 2026
Copilot AI review requested due to automatic review settings April 9, 2026 09:32
@yan-3005 yan-3005 added safe to test Add this label to run secure Github workflows on PRs UI UI specific issues labels Apr 9, 2026
@yan-3005 yan-3005 self-assigned this Apr 9, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a “Proposed Changes” preview for governance approval tasks by formatting an entity’s changeDescription into activity-feed-style messages, persisting them in the task thread, and rendering them in the Task UI with injected clickable links for certain changed fields.

Changes:

  • Backend: build per-field formatted change messages from changeDescription and write a markdown bullet list into the approval task thread’s message.
  • UI: render the proposed-changes block for approval tasks and inject clickable links into diff spans for selected fields.
  • i18n/UI styling: add a new label key and style the proposed-changes section.

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/userTask/impl/CreateApprovalTaskImpl.java Builds formatted diffs from changeDescription and stores them into the task thread message for approvals.
openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx Renders “Proposed Changes” section and injects hyperlinks into diff spans in the rendered markdown/HTML.
openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/task-tab-new.less Adds styling for the proposed-changes container/title.
openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json Adds label.proposed-change-plural translation key.
openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json Adds label.proposed-change-plural translation key.
openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json Adds label.proposed-change-plural translation key.
openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json Adds label.proposed-change-plural translation key.
openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json Adds label.proposed-change-plural translation key.
openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json Adds label.proposed-change-plural translation key.
openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json Adds label.proposed-change-plural translation key.
openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json Adds label.proposed-change-plural translation key.
openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json Adds label.proposed-change-plural translation key.
openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json Adds label.proposed-change-plural translation key.
openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json Adds label.proposed-change-plural translation key.
openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json Adds label.proposed-change-plural translation key.
openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json Adds label.proposed-change-plural translation key.
openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json Adds label.proposed-change-plural translation key.
openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json Adds label.proposed-change-plural translation key.
openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json Adds label.proposed-change-plural translation key.
openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json Adds label.proposed-change-plural translation key.
openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json Adds label.proposed-change-plural translation key.
openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json Adds label.proposed-change-plural translation key.

Comment on lines +166 to +167
const DIFF_SPAN_RE =
/(<span[^>]*class="diff-(?:added|removed)"[^>]*>)([^<]+)(<\/span>)/g;
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

DIFF_SPAN_RE only matches spans whose class attribute is exactly "diff-added"/"diff-removed". Other diff renderers in the UI use multiple classes on the element (e.g. class="ant-typography diff-added"), which would prevent link injection from working. Consider widening the regex to match diff-added/diff-removed anywhere within the class attribute value (word-boundary match), rather than requiring an exact class value.

Copilot uses AI. Check for mistakes.
Comment on lines +160 to +163
{
pattern: /\*\*(owners|reviewers|experts)\*\*/,
getUrl: (fqn) => getUserPath(fqn),
},
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

The owners|reviewers|experts link mapping uses getUserPath(fqn) for every diff value. In formatted change messages, owner values are derived from EntityReference.displayName (not necessarily the username), and owners can also be teams; both cases will generate incorrect URLs (e.g. team owners should use the team details path). Consider either (1) emitting explicit entity links in the backend formatter output for these fields so the UI can render correct links, or (2) only linking when you can reliably disambiguate user vs team and have a stable identifier (name/FQN), otherwise skip link injection for these fields.

Copilot uses AI. Check for mistakes.
Comment on lines +158 to +170
void applyChangePreview(
Thread taskThread, EntityInterface entity, MessageParser.EntityLink about) {
final List<Thread> perFieldThreads = buildFormattedDiffs(entity, about);
taskThread.withCardStyle(null).withFieldOperation(null).withFeedInfo(null);
if (perFieldThreads.isEmpty()) {
taskThread.withMessage(null);
return;
}
final StringBuilder builder = new StringBuilder();
for (final Thread perField : perFieldThreads) {
builder.append("- ").append(perField.getMessage()).append('\n');
}
taskThread.withMessage(builder.toString());
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

applyChangePreview overwrites thread.message with a markdown bullet list containing raw HTML diff spans (e.g. <span class="diff-added">…</span>). Other UI surfaces render task.message as plain text (e.g., notifications for glossary approval tasks), which will now show escaped HTML / list markers and read poorly. Consider keeping thread.message as a plain-text summary and storing the preview elsewhere (e.g. an existing task field), or updating all consumers of thread.message for approval tasks to render it as markdown/HTML consistently.

Copilot uses AI. Check for mistakes.
@yan-3005
Copy link
Copy Markdown
Contributor Author

yan-3005 commented Apr 9, 2026

Re: Copilot comment on CreateApprovalTaskImpl.java — investigated all consumers of thread.getMessage() for task threads:

  1. WebsocketNotificationHandler.handleTaskNotification — for new tasks (postsCount == 0), it sends the entire Thread JSON to assignees via sendToManyWithUUID. The message field is not extracted as plain text independently; the UI receives the full thread object and renders it the same way as the REST response. No regression.

  2. EmailUtil.sendTaskAssignmentNotificationToUser — this is the method that does .add("taskName", thread.getMessage()) inside the email template. It is never called anywhere in the codebase. Dead code. No regression.

  3. handleConversationNotification — parses thread.getMessage() for @-mentions, but this method is only dispatched for ThreadType.Conversation, not ThreadType.Task. Approval tasks never reach this branch.

Copilot's concern is valid as a future risk — if task assignment email notifications are wired up later, they'd inherit the raw HTML diff spans from message. Worth flagging at that point. No changes needed now.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 9, 2026

Jest test Coverage

UI tests summary

Lines Statements Branches Functions
Coverage: 63%
63.71% (59606/93549) 43.64% (31376/71888) 46.71% (9426/20176)

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 9, 2026

🟡 Playwright Results — all passed (36 flaky)

✅ 3645 passed · ❌ 0 failed · 🟡 36 flaky · ⏭️ 89 skipped

Shard Passed Failed Flaky Skipped
🟡 Shard 1 478 0 2 4
🟡 Shard 2 649 0 2 7
🟡 Shard 3 644 0 12 1
🟡 Shard 4 626 0 8 27
🟡 Shard 5 610 0 1 42
🟡 Shard 6 638 0 11 8
🟡 36 flaky test(s) (passed on retry)
  • Flow/Tour.spec.ts › Tour should work from help section (shard 1, 1 retry)
  • Pages/UserCreationWithPersona.spec.ts › Create user with persona and verify on profile (shard 1, 1 retry)
  • Features/BulkEditEntity.spec.ts › Glossary (shard 2, 1 retry)
  • Features/ChangeSummaryBadge.spec.ts › Automated badge should appear on entity description with Automated source (shard 2, 1 retry)
  • Features/RestoreEntityInheritedFields.spec.ts › Validate restore with Inherited domain and data products assigned (shard 3, 1 retry)
  • Features/RestoreEntityInheritedFields.spec.ts › Validate restore with Inherited domain and data products assigned (shard 3, 1 retry)
  • Features/RestoreEntityInheritedFields.spec.ts › Validate restore with Inherited domain and data products assigned (shard 3, 1 retry)
  • Features/RestoreEntityInheritedFields.spec.ts › Validate restore with Inherited domain and data products assigned (shard 3, 1 retry)
  • Features/RestoreEntityInheritedFields.spec.ts › Validate restore with Inherited domain and data products assigned (shard 3, 1 retry)
  • Features/RestoreEntityInheritedFields.spec.ts › Validate restore with Inherited domain and data products assigned (shard 3, 2 retries)
  • Features/RestoreEntityInheritedFields.spec.ts › Validate restore with Inherited domain and data products assigned (shard 3, 1 retry)
  • Features/RestoreEntityInheritedFields.spec.ts › Validate restore with Inherited domain and data products assigned (shard 3, 2 retries)
  • Features/RTL.spec.ts › Verify Following widget functionality (shard 3, 1 retry)
  • Flow/CustomizeWidgets.spec.ts › Following Assets Widget (shard 3, 1 retry)
  • Flow/PersonaDeletionUserProfile.spec.ts › User profile loads correctly before and after persona deletion (shard 3, 1 retry)
  • Flow/PersonaFlow.spec.ts › Set default persona for team should work properly (shard 3, 1 retry)
  • Pages/Customproperties-part2.spec.ts › entityReferenceList shows item count, scrollable list, no expand toggle (shard 4, 1 retry)
  • Pages/DataContracts.spec.ts › Create Data Contract and validate for Container (shard 4, 1 retry)
  • Pages/DataContracts.spec.ts › Create Data Contract and validate for Database (shard 4, 1 retry)
  • Pages/DomainAdvanced.spec.ts › Domain expert can manage data products (shard 4, 1 retry)
  • Pages/Domains.spec.ts › Subdomain rename does not affect parent domain and updates nested children (shard 4, 1 retry)
  • Pages/Entity.spec.ts › Tier Add, Update and Remove (shard 4, 1 retry)
  • Pages/Entity.spec.ts › Tag Add, Update and Remove (shard 4, 1 retry)
  • Pages/Entity.spec.ts › Glossary Term Add, Update and Remove (shard 4, 1 retry)
  • Pages/Glossary.spec.ts › Add and Remove Assets (shard 5, 2 retries)
  • Pages/Glossary.spec.ts › Column dropdown drag-and-drop functionality for Glossary Terms table (shard 6, 1 retry)
  • Pages/Lineage/DataAssetLineage.spec.ts › verify create lineage for entity - Table (shard 6, 1 retry)
  • Pages/Lineage/DataAssetLineage.spec.ts › verify create lineage for entity - Dashboard (shard 6, 1 retry)
  • Pages/Lineage/DataAssetLineage.spec.ts › Column lineage for table -> searchIndex (shard 6, 1 retry)
  • Pages/Lineage/LineageFilters.spec.ts › Verify lineage schema filter selection (shard 6, 1 retry)
  • ... and 6 more

📦 Download artifacts

How to debug locally
# Download playwright-test-results-<shard> artifact and unzip
npx playwright show-trace path/to/trace.zip    # view trace

Copilot AI review requested due to automatic review settings April 12, 2026 19:33
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.

Comment on lines +172 to +189
const addEntityLinks = (message: string): string =>
message
.split('\n')
.map((line) => {
const matcher = FIELD_LINK_MAP.find((m) => m.pattern.test(line));
if (!matcher) {
return line;
}

return line.replace(
DIFF_SPAN_RE,
(_, openTag: string, fqn: string, closeTag: string) =>
`${openTag}<a href="${matcher.getUrl(fqn.trim())}">${escapeHtml(
fqn
)}</a>${closeTag}`
);
})
.join('\n');
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

addEntityLinks wraps the entire text inside each diff-added/diff-removed span with a single link. The backend formatter serializes array values as a comma-separated string (e.g., multiple tags / glossary terms become "tagA, tagB"), so the generated href will point to an encoded combined string that won’t resolve to a real entity page. Consider splitting the span text on commas (preserving separators/whitespace) and linkifying each individual value so multi-value changes produce valid links.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings April 13, 2026 16:12
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated 5 comments.

Comment on lines +75 to +80
@Test
void extractIdentifiers_arrayOfStrings_returnsAll() {
String json = "[\"one\",\"two\"]";
assertEquals(List.of("one", "two"), ChangePreviewUtils.extractIdentifiers(json));
}

Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

Unit tests for extractIdentifiers() only cover JSON-in-a-string cases, but production FieldChange values are commonly List/Map objects after deserialization. Add test cases for list/map inputs (e.g. List<Map<String,Object>> for tags/owners and Map<String,Object> for single refs) to prevent regressions once the extractor is updated.

Suggested change
@Test
void extractIdentifiers_arrayOfStrings_returnsAll() {
String json = "[\"one\",\"two\"]";
assertEquals(List.of("one", "two"), ChangePreviewUtils.extractIdentifiers(json));
}
@Test
void extractIdentifiers_arrayOfTagMaps_returnsAllTagFqns() {
List<Map<String, Object>> tags =
List.of(
Map.of("tagFQN", "PII.Sensitive", "name", "Sensitive"),
Map.of("tagFQN", "PersonalData.Personal", "name", "Personal"));
assertEquals(
List.of("PII.Sensitive", "PersonalData.Personal"),
ChangePreviewUtils.extractIdentifiers(tags));
}
@Test
void extractIdentifiers_arrayOfOwnerMaps_returnsDisplayNames() {
List<Map<String, Object>> owners =
List.of(
Map.of("displayName", "Aaron Johnson", "name", "aaron.johnson"),
Map.of("displayName", "Jane Doe", "name", "jane.doe"));
assertEquals(List.of("Aaron Johnson", "Jane Doe"), ChangePreviewUtils.extractIdentifiers(owners));
}
@Test
void extractIdentifiers_singleReferenceMap_returnsFullyQualifiedName() {
Map<String, Object> reference =
Map.of("fullyQualifiedName", "Marketing.Glossary1", "displayName", "Glossary 1");
assertEquals(List.of("Marketing.Glossary1"), ChangePreviewUtils.extractIdentifiers(reference));
}
@Test
void extractIdentifiers_singleNameOnlyMap_returnsName() {
Map<String, Object> reference = Map.of("name", "myEntity");
assertEquals(List.of("myEntity"), ChangePreviewUtils.extractIdentifiers(reference));
}
@Test
void extractIdentifiers_arrayOfStrings_returnsAll() {
String json = "[\"one\",\"two\"]";
assertEquals(List.of("one", "two"), ChangePreviewUtils.extractIdentifiers(json));
}
@Test
void extractIdentifiers_listOfStrings_returnsAll() {
assertEquals(List.of("one", "two"), ChangePreviewUtils.extractIdentifiers(List.of("one", "two")));
}

Copilot uses AI. Check for mistakes.
"remove-lineage-edge": "删除血缘连线",
"rename-entity": "修改{{entity}}的名称和显示名",
"request-approval-message": "批准请求",
"request-approval-notification": "Approval required for",
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

request-approval-notification is added with an English value ("Approval required for") in this non-English locale file, which will cause mixed-language UI. Please provide a localized translation here (and in the other locale files updated in this PR) or fall back to an existing translated key if you want a shared message.

Suggested change
"request-approval-notification": "Approval required for",
"request-approval-notification": "需要批准",

Copilot uses AI. Check for mistakes.
Comment on lines +162 to +180
const normalized: ProposedChanges = {};
for (const [field, value] of Object.entries(parsed)) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
continue;
}
const entry = value as Record<string, unknown>;
normalized[field] = {
added: Array.isArray(entry.added)
? (entry.added as unknown[]).filter(
(v): v is string => typeof v === 'string'
)
: [],
removed: Array.isArray(entry.removed)
? (entry.removed as unknown[]).filter(
(v): v is string => typeof v === 'string'
)
: [],
};
}
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

parseProposedChanges builds normalized as a plain object and writes arbitrary keys from parsed JSON into it. If taskThread.message ever contains a JSON object with keys like __proto__/constructor/prototype, this can trigger prototype-pollution style issues. Use a null-prototype object (e.g. Object.create(null)) and/or explicitly skip those reserved keys when copying entries.

Copilot uses AI. Check for mistakes.
Comment on lines +1172 to +1199
@@ -1135,6 +1191,66 @@ export const TaskTabNew = ({
</Col>
<Divider className="m-0" type="horizontal" />
<Col span={24}>{taskHeader}</Col>
{proposedChanges !== null && (
<Col span={24}>
<div className="task-proposed-changes">
<Typography.Text className="task-proposed-changes-title">
{t('label.proposed-change-plural')}
</Typography.Text>
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

The Proposed Changes card is rendered solely based on taskThread.message looking like JSON, regardless of task type. This can cause non-approval tasks (or any task message that happens to be JSON) to show a Proposed Changes section unexpectedly. Gate both parsing and rendering behind taskDetails?.type === TaskType.RequestApproval (or isTaskGlossaryApproval).

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +80
try {
JsonValue json = JsonUtils.readJson(fieldValue.toString());
if (json.getValueType() == JsonValue.ValueType.ARRAY) {
return extractFromArray(json.asJsonArray());
}
if (json.getValueType() == JsonValue.ValueType.OBJECT) {
return extractFromObject(json.asJsonObject());
}
} catch (Exception e) {
// not JSON — treat as a plain string
}
return List.of(fieldValue.toString().strip());
}

private static List<String> extractFromArray(JsonArray array) {
List<String> result = new ArrayList<>();
for (JsonValue item : array) {
if (item.getValueType() == JsonValue.ValueType.OBJECT) {
result.addAll(extractFromObject(item.asJsonObject()));
} else if (item.getValueType() == JsonValue.ValueType.STRING) {
result.add(((JsonString) item).getString().strip());
}
}
return result;
}

private static List<String> extractFromObject(JsonObject obj) {
Set<String> keys = obj.keySet();
if (keys.contains("tagFQN")) return List.of(obj.getString("tagFQN"));
if (keys.contains("fullyQualifiedName")) return List.of(obj.getString("fullyQualifiedName"));
if (keys.contains("displayName")) return List.of(obj.getString("displayName"));
if (keys.contains("name")) return List.of(obj.getString("name"));
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

extractIdentifiers() currently assumes fieldValue.toString() is JSON and falls back to treating the whole value as a single string. In practice, FieldChange values are often deserialized as List/Map (see existing FieldChangeValueExtractor), so this will produce incorrect identifiers like [{tagFQN=...}] or Java toString() output instead of extracting tagFQN/fullyQualifiedName/etc. Update the extractor to handle List/Map inputs directly (and/or recursively parse JSON when the value is a string) so change previews consistently contain usable identifiers.

Suggested change
try {
JsonValue json = JsonUtils.readJson(fieldValue.toString());
if (json.getValueType() == JsonValue.ValueType.ARRAY) {
return extractFromArray(json.asJsonArray());
}
if (json.getValueType() == JsonValue.ValueType.OBJECT) {
return extractFromObject(json.asJsonObject());
}
} catch (Exception e) {
// not JSON — treat as a plain string
}
return List.of(fieldValue.toString().strip());
}
private static List<String> extractFromArray(JsonArray array) {
List<String> result = new ArrayList<>();
for (JsonValue item : array) {
if (item.getValueType() == JsonValue.ValueType.OBJECT) {
result.addAll(extractFromObject(item.asJsonObject()));
} else if (item.getValueType() == JsonValue.ValueType.STRING) {
result.add(((JsonString) item).getString().strip());
}
}
return result;
}
private static List<String> extractFromObject(JsonObject obj) {
Set<String> keys = obj.keySet();
if (keys.contains("tagFQN")) return List.of(obj.getString("tagFQN"));
if (keys.contains("fullyQualifiedName")) return List.of(obj.getString("fullyQualifiedName"));
if (keys.contains("displayName")) return List.of(obj.getString("displayName"));
if (keys.contains("name")) return List.of(obj.getString("name"));
return extractIdentifiersInternal(fieldValue);
}
private static List<String> extractIdentifiersInternal(Object fieldValue) {
if (fieldValue == null) {
return List.of();
}
if (fieldValue instanceof List<?> listValue) {
List<String> result = new ArrayList<>();
for (Object item : listValue) {
result.addAll(extractIdentifiersInternal(item));
}
return result;
}
if (fieldValue instanceof Map<?, ?> mapValue) {
return extractFromMap(mapValue);
}
if (fieldValue instanceof String stringValue) {
String strippedValue = stringValue.strip();
if (strippedValue.isEmpty()) {
return List.of();
}
try {
JsonValue json = JsonUtils.readJson(strippedValue);
return extractFromJsonValue(json);
} catch (Exception e) {
return List.of(strippedValue);
}
}
String stringValue = fieldValue.toString().strip();
return stringValue.isEmpty() ? List.of() : List.of(stringValue);
}
private static List<String> extractFromJsonValue(JsonValue jsonValue) {
if (jsonValue == null) {
return List.of();
}
return switch (jsonValue.getValueType()) {
case ARRAY -> extractFromArray(jsonValue.asJsonArray());
case OBJECT -> extractFromObject(jsonValue.asJsonObject());
case STRING -> extractIdentifiersInternal(((JsonString) jsonValue).getString());
default -> List.of();
};
}
private static List<String> extractFromArray(JsonArray array) {
List<String> result = new ArrayList<>();
for (JsonValue item : array) {
result.addAll(extractFromJsonValue(item));
}
return result;
}
private static List<String> extractFromMap(Map<?, ?> map) {
List<String> preferredIdentifier = extractPreferredIdentifier(map);
if (!preferredIdentifier.isEmpty()) {
return preferredIdentifier;
}
List<String> result = new ArrayList<>();
for (Object value : map.values()) {
result.addAll(extractIdentifiersInternal(value));
}
return result;
}
private static List<String> extractFromObject(JsonObject obj) {
List<String> preferredIdentifier = extractPreferredIdentifier(obj);
if (!preferredIdentifier.isEmpty()) {
return preferredIdentifier;
}
List<String> result = new ArrayList<>();
for (JsonValue value : obj.values()) {
result.addAll(extractFromJsonValue(value));
}
return result;
}
private static List<String> extractPreferredIdentifier(Map<?, ?> map) {
for (String key : List.of("tagFQN", "fullyQualifiedName", "displayName", "name")) {
Object value = map.get(key);
if (value != null) {
String identifier = value.toString().strip();
if (!identifier.isEmpty()) {
return List.of(identifier);
}
}
}
return List.of();
}
private static List<String> extractPreferredIdentifier(JsonObject obj) {
Set<String> keys = obj.keySet();
if (keys.contains("tagFQN")) return List.of(obj.getString("tagFQN").strip());
if (keys.contains("fullyQualifiedName")) return List.of(obj.getString("fullyQualifiedName").strip());
if (keys.contains("displayName")) return List.of(obj.getString("displayName").strip());
if (keys.contains("name")) return List.of(obj.getString("name").strip());

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated 6 comments.

Comment on lines +51 to +53
? t('message.request-approval-notification')
: task.message}
</span>
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

In the RequestApproval notification text, relying on a trailing space inside the i18n string causes locales without that trailing space to render the entity link immediately adjacent (e.g., "Approval required for"). Add the separator explicitly in JSX (or include the entity name as an interpolation param) and keep the translation value free of trailing whitespace.

Suggested change
? t('message.request-approval-notification')
: task.message}
</span>
? t('message.request-approval-notification').trimEnd()
: task.message}
</span>{' '}

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +58
public static List<String> extractIdentifiers(Object fieldValue) {
if (nullOrEmpty(fieldValue)) return List.of();
if (fieldValue instanceof List<?> list) {
List<String> result = new ArrayList<>();
for (Object item : list) {
result.addAll(extractIdentifiers(item));
}
return result;
}
if (fieldValue instanceof Map<?, ?> map) {
return extractFromMap(map);
}
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

ChangePreviewUtils.extractIdentifiers starts with nullOrEmpty(fieldValue), but because fieldValue is typed as Object, it binds to CommonUtil.nullOrEmpty(Object) (which checks object.toString().isEmpty()), not the Map/Collection overloads. As a result, an empty map (e.g., {}) is treated as non-empty and extractFromMap returns a serialized {} identifier, which would surface in the UI. Consider handling Map/Collection emptiness explicitly before calling nullOrEmpty, or change the first check to an instanceof chain (String/List/Map) that uses the correct emptiness semantics.

Copilot uses AI. Check for mistakes.
className="task-proposed-changes-field-row"
key={field}>
<Typography.Text className="task-proposed-changes-field-name">
{field}
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

The proposed-changes field label currently renders the raw field key (e.g., glossaryTerms) and CSS text-transform: capitalize will produce unreadable labels like "Glossaryterms". Consider formatting with startCase(field) or mapping known keys to existing i18n labels so the UI shows human-friendly names.

Suggested change
{field}
{startCase(field)}

Copilot uses AI. Check for mistakes.
Comment on lines +1215 to +1242
{removed.map((val) =>
getUrl ? (
<Link
className="task-proposed-changes-chip task-proposed-changes-chip--removed"
key={`removed-${val}`}
to={getUrl(val)}>
{val}
</Link>
) : (
<span
className="task-proposed-changes-chip task-proposed-changes-chip--removed"
key={`removed-${val}`}>
{val}
</span>
)
)}
{added.map((val) =>
getUrl ? (
<Link
className="task-proposed-changes-chip task-proposed-changes-chip--added"
key={`added-${val}`}
to={getUrl(val)}>
{val}
</Link>
) : (
<span
className="task-proposed-changes-chip task-proposed-changes-chip--added"
key={`added-${val}`}>
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

The chip keys use only the value string (e.g. key={\added-${val}`}`), which can collide if duplicates exist in the backend payload, leading to React key warnings and unstable rendering. Use a stable unique key (e.g., include the index or ensure de-duplication before rendering).

Suggested change
{removed.map((val) =>
getUrl ? (
<Link
className="task-proposed-changes-chip task-proposed-changes-chip--removed"
key={`removed-${val}`}
to={getUrl(val)}>
{val}
</Link>
) : (
<span
className="task-proposed-changes-chip task-proposed-changes-chip--removed"
key={`removed-${val}`}>
{val}
</span>
)
)}
{added.map((val) =>
getUrl ? (
<Link
className="task-proposed-changes-chip task-proposed-changes-chip--added"
key={`added-${val}`}
to={getUrl(val)}>
{val}
</Link>
) : (
<span
className="task-proposed-changes-chip task-proposed-changes-chip--added"
key={`added-${val}`}>
{removed.map((val, index) =>
getUrl ? (
<Link
className="task-proposed-changes-chip task-proposed-changes-chip--removed"
key={`${field}-removed-${val}-${index}`}
to={getUrl(val)}>
{val}
</Link>
) : (
<span
className="task-proposed-changes-chip task-proposed-changes-chip--removed"
key={`${field}-removed-${val}-${index}`}>
{val}
</span>
)
)}
{added.map((val, index) =>
getUrl ? (
<Link
className="task-proposed-changes-chip task-proposed-changes-chip--added"
key={`${field}-added-${val}-${index}`}
to={getUrl(val)}>
{val}
</Link>
) : (
<span
className="task-proposed-changes-chip task-proposed-changes-chip--added"
key={`${field}-added-${val}-${index}`}>

Copilot uses AI. Check for mistakes.
font-size: 12px;
font-weight: 500;
padding-top: 2px;
text-transform: capitalize;
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

text-transform: capitalize only uppercases the first character and won't properly format camelCase field names (e.g., relatedTerms becomes "Relatedterms"). Since the UI is showing field identifiers, consider removing this and formatting the label in React (e.g., startCase) so the rendered text is correct.

Suggested change
text-transform: capitalize;

Copilot uses AI. Check for mistakes.
"remove-lineage-edge": "Remove lineage edge",
"rename-entity": "Rename the Name and Display Name for the {{entity}}.",
"request-approval-message": "Approval request for",
"request-approval-notification": "Approval required for ",
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

The en-US translation value for message.request-approval-notification contains a trailing space. Trailing whitespace in i18n strings is easy to miss and makes localization inconsistent; it’s safer to remove it and add spacing explicitly in JSX where the text is composed with links.

Suggested change
"request-approval-notification": "Approval required for ",
"request-approval-notification": "Approval required for",

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

IceS2
IceS2 previously approved these changes Apr 16, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated 5 comments.

Comment on lines +231 to +235
.task-proposed-changes {
border-radius: 12px;
border: 0.5px solid #eaecf5;
background: @grey-9;
padding: 16px;
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

The new proposed-changes container uses a hardcoded border color #eaecf5. There is already a corresponding Less variable (@grey-15) in src/styles/variables.less; using it avoids duplicating palette values and keeps styling consistent across components.

Copilot uses AI. Check for mistakes.
"remove-lineage-edge": "删除血缘连线",
"rename-entity": "修改{{entity}}的名称和显示名",
"request-approval-message": "批准请求",
"request-approval-notification": "Approval required for",
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

request-approval-notification is added to the zh-CN locale but the value is still English ("Approval required for"), which makes the notification partially untranslated for this locale. Either provide the proper localized translation here (and in other non-en locales) or omit the key from non-en locale files so i18n can fall back to en-us consistently.

Suggested change
"request-approval-notification": "Approval required for",
"request-approval-notification": "需要批准:",

Copilot uses AI. Check for mistakes.
Comment on lines +1196 to +1202
{proposedChanges !== null && (
<Col span={24}>
<div className="task-proposed-changes">
<Typography.Text className="task-proposed-changes-title">
{t('label.proposed-change-plural')}
</Typography.Text>
<div className="task-proposed-changes-fields">
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

The new Proposed Changes branch (JSON parsing from taskThread.message and rendering the chip list) isn’t covered by the existing TaskTabNew unit tests. Add test cases that pass a JSON taskThread.message and assert the Proposed Changes title renders, the added/removed chips render, and mapped fields render as links with the expected to URLs.

Copilot uses AI. Check for mistakes.
Comment on lines +166 to +170
try {
Map<String, FieldDiff> merged =
mergeChangeMaps(parseChangeMap(oldMessage), buildChangeMap(changeDescription));
taskThread.withMessage(merged.isEmpty() ? "{}" : JsonUtils.pojoToJson(merged));
} catch (Exception e) {
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

applyChangePreview() serializes the change preview into thread.message (JSON like { "tags": ... }). Thread.message is consumed as human-readable text in other channels (e.g., task assignment email templates populate taskName from thread.getMessage()), so this will leak raw JSON into email/Slack/Teams notifications and other renderers. Consider keeping message human-readable (e.g., "Approval required for") and storing the structured preview elsewhere (task fields), or prefixing with a readable string and updating UI parsing to extract only the JSON portion; alternatively update downstream notification/template renderers to detect/format the JSON payload for RequestApproval tasks.

Copilot uses AI. Check for mistakes.
Comment on lines +281 to +283
background-color: #ecfdf3;
color: #027a48;
border: 1px solid #a9efc5;
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

New proposed-changes UI styles introduce additional hardcoded hex colors (e.g., #ecfdf3, #027a48, #a9efc5). The codebase already defines equivalent Less variables (e.g., @green-9, @green-10, @green-16) in src/styles/variables.less; using those keeps theming consistent and simplifies future palette changes.

Suggested change
background-color: #ecfdf3;
color: #027a48;
border: 1px solid #a9efc5;
background-color: @green-9;
color: @green-10;
border: 1px solid @green-16;

Copilot uses AI. Check for mistakes.
IceS2
IceS2 previously approved these changes Apr 16, 2026
siddhant1
siddhant1 previously approved these changes Apr 17, 2026
Copy link
Copy Markdown
Member

@siddhant1 siddhant1 left a comment

Choose a reason for hiding this comment

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

UI

@gitar-bot
Copy link
Copy Markdown

gitar-bot Bot commented Apr 17, 2026

Code Review ✅ Approved 5 resolved / 5 findings

Implements clickable entity links in approval tasks while resolving team routing, HTML escaping, schema violations, redundant calls, and deserialization nullability issues. No remaining findings identified.

✅ 5 resolved
Bug: Owner/reviewer links always route to /users, broken for teams

📄 openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx:160-163
The FIELD_LINK_MAP entry for owners|reviewers|experts uses getUserPath(fqn) unconditionally. In OpenMetadata, owners can be either users or teams. When the owner is a team, this generates a /users/{teamFqn} URL which will 404 or show the wrong page — the correct path would be the team details page.

The codebase already has a getOwnerPath() utility in ownerUtils.ts that checks the owner type and routes to the correct path, but it requires the owner type as input. Since the diff span text only contains the FQN (no type info), there's no way to distinguish user from team owners in the current approach.

Security: FQN text inserted into link innerHTML without HTML escaping

📄 openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx:178-184
In addEntityLinks, the captured fqn text is inserted both into the href attribute (safe — getClassificationTagPath etc. use encodeURIComponent) and as innerHTML text (>${fqn}</a>) without HTML entity escaping. While the regex [^<]+ prevents < in the FQN, characters like & or > in entity names could produce malformed HTML. DOMPurify (getSanitizeContent) will likely handle this gracefully, but it's relying on the sanitizer as the sole defense rather than escaping at the point of interpolation.

Risk is low because the FQN originates from server-generated change descriptions, not raw user input, and DOMPurify is applied downstream.

Bug: thread.message set to null violates schema required constraint

📄 openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/util/ChangePreviewUtils.java:186-193 📄 openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/elements/nodes/userTask/impl/CreateApprovalTaskImpl.java:153
In applyChangePreview, when the merged change map is empty (all changes cancel out), message is set to null (line 193). Similarly, when hasNoChanges(cd) is true and oldMessage is null (the new-thread creation path at CreateApprovalTaskImpl line 153), message is also set to null (line 187).

The Thread JSON schema declares message as a required field (thread.json line 370: "required": ["id", "about", "message"]), so persisting a Thread with a null message can cause schema validation failures or database constraint violations when feedRepository.create(thread) or feedDAO().update(...) is called.

Performance: parseProposedChanges called twice with same argument

📄 openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx:1164 📄 openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx:1172
parseProposedChanges(taskThread.message ?? '') is called once in the condition check (line 1164) and again inside the render block (line 1172). This parses the same JSON string twice on every render. While not expensive, it's easy to avoid with a local variable or useMemo.

Edge Case: FieldDiff record fields can be null after Jackson deserialization

📄 openmetadata-service/src/main/java/org/openmetadata/service/governance/workflows/util/ChangePreviewUtils.java:50-58
When parseChangeMap deserializes JSON where "added" or "removed" is missing from a field entry (e.g. {"tags":{"added":["x"]}}), Jackson will set the missing record component to null. This causes NullPointerException in both FieldDiff.merge() (which calls .stream() on the lists) and FieldDiff.isEmpty() (which calls .isEmpty()).

While current code always writes both fields, parseChangeMap reads oldMessage from the database — data could be incomplete due to corruption, manual edits, or future code changes. The old toTypedFieldEntry method defaulted missing keys to List.of(), so this is a regression in robustness.

Options

Display: compact → Showing less information.

Comment with these commands to change:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated 2 comments.

Comment on lines +1211 to +1213
<Typography.Text className="task-proposed-changes-field-name">
{startCase(field)}
</Typography.Text>
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

Field labels in the Proposed Changes section are derived via startCase(field), which produces user-facing text that is not localized. Please map known field keys to i18n labels (with a sensible fallback) so this UI remains fully translatable.

Copilot uses AI. Check for mistakes.
setHasAddedComment(false);
}, [taskThread.id]);

const proposedChanges = isTaskGlossaryApproval
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

isTaskGlossaryApproval is used to gate the Proposed Changes rendering, but it is simply TaskType.RequestApproval (not glossary-specific). Consider renaming this boolean (and associated constants like GLOSSARY_TASK_ACTION_LIST usage if applicable) to reflect its actual meaning (e.g., isRequestApprovalTask) to avoid confusion as this feature expands beyond glossary entities.

Suggested change
const proposedChanges = isTaskGlossaryApproval
const isRequestApprovalTask = isTaskGlossaryApproval;
const proposedChanges = isRequestApprovalTask

Copilot uses AI. Check for mistakes.
@sonarqubecloud
Copy link
Copy Markdown

@sonarqubecloud
Copy link
Copy Markdown

@yan-3005
Copy link
Copy Markdown
Contributor Author

Cherry picked to 1.12.7 4ace688

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend safe to test Add this label to run secure Github workflows on PRs UI UI specific issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: show proposed changes with clickable entity links in approval tasks

6 participants