From 29458682686d686f2da9daef65994101dd206c88 Mon Sep 17 00:00:00 2001 From: Philippe Perrin Date: Fri, 20 Dec 2019 11:26:12 +0100 Subject: [PATCH] SONAR-12720 Review tab displays hotspot's comments --- .../src/main/js/api/security-hotspots.ts | 24 +- .../securityHotspots/__tests__/utils-test.ts | 46 ++- .../components/HotspotViewerRenderer.tsx | 8 +- .../HotspotViewerReviewHistoryTab.tsx | 25 +- .../components/HotspotViewerTabs.tsx | 4 +- .../__tests__/HotspotViewerRenderer-test.tsx | 4 +- .../HotspotViewerReviewHistoryTab-test.tsx | 4 + .../HotspotSnippetContainer-test.tsx.snap | 25 +- ...spotSnippetContainerRenderer-test.tsx.snap | 25 +- .../HotspotViewerRenderer-test.tsx.snap | 330 +++++++++++++++--- ...otspotViewerReviewHistoryTab-test.tsx.snap | 58 ++- .../HotspotViewerTabs-test.tsx.snap | 36 +- .../main/js/apps/securityHotspots/utils.ts | 25 +- .../js/helpers/mocks/security-hotspots.ts | 10 +- .../src/main/js/types/security-hotspots.ts | 24 +- .../resources/org/sonar/l10n/core.properties | 1 + 16 files changed, 541 insertions(+), 108 deletions(-) diff --git a/server/sonar-web/src/main/js/api/security-hotspots.ts b/server/sonar-web/src/main/js/api/security-hotspots.ts index cd4bd24150a..78ec7567f62 100644 --- a/server/sonar-web/src/main/js/api/security-hotspots.ts +++ b/server/sonar-web/src/main/js/api/security-hotspots.ts @@ -59,5 +59,27 @@ export function getSecurityHotspots( } export function getSecurityHotspotDetails(securityHotspotKey: string): Promise { - return getJSON('/api/hotspots/show', { hotspot: securityHotspotKey }).catch(throwGlobalError); + return getJSON('/api/hotspots/show', { hotspot: securityHotspotKey }) + .then((response: Hotspot & { users: T.UserBase[] }) => { + const { users, ...hotspot } = response; + + if (users) { + if (hotspot.assignee) { + hotspot.assigneeUser = users.find(u => u.login === hotspot.assignee) || { + active: true, + login: hotspot.assignee + }; + } + hotspot.authorUser = users.find(u => u.login === hotspot.author) || { + active: true, + login: hotspot.author + }; + hotspot.comment.forEach(c => { + c.user = users.find(u => u.login === c.login) || { active: true, login: c.login }; + }); + } + + return hotspot; + }) + .catch(throwGlobalError); } diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/utils-test.ts b/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/utils-test.ts index cf0ec56d0d7..c93b4e01bcb 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/utils-test.ts +++ b/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/utils-test.ts @@ -18,6 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { mockHotspot, mockRawHotspot } from '../../../helpers/mocks/security-hotspots'; +import { mockUser } from '../../../helpers/testMocks'; import { ReviewHistoryType, RiskExposure } from '../../../types/security-hotspots'; import { getHotspotReviewHistory, groupByCategory, mapRules, sortHotspots } from '../utils'; @@ -158,25 +159,56 @@ describe('getHotspotReviewHistory', () => { } ] }; + const commentElement = { + key: 'comment-1', + createdAt: '2018-09-10', + htmlText: 'TEST', + markdown: '*TEST*', + updatable: true, + login: 'dude-1', + user: mockUser({ login: 'dude-1' }) + }; + const commentElement1 = { + key: 'comment-2', + createdAt: '2018-09-11', + htmlText: 'TEST', + markdown: '*TEST*', + updatable: true, + login: 'dude-2', + user: mockUser({ login: 'dude-2' }) + }; const hotspot = mockHotspot({ creationDate: '2018-09-01', - changelog: [changelogElement] + changelog: [changelogElement], + comment: [commentElement, commentElement1] }); const history = getHotspotReviewHistory(hotspot); - expect(history.length).toBe(2); + expect(history.length).toBe(4); expect(history[0]).toEqual( expect.objectContaining({ type: ReviewHistoryType.Creation, date: hotspot.creationDate, - user: { - avatar: hotspot.author.avatar, - name: hotspot.author.name, - active: hotspot.author.active - } + user: hotspot.authorUser }) ); expect(history[1]).toEqual( + expect.objectContaining({ + type: ReviewHistoryType.Comment, + date: commentElement.createdAt, + user: commentElement.user, + html: commentElement.htmlText + }) + ); + expect(history[2]).toEqual( + expect.objectContaining({ + type: ReviewHistoryType.Comment, + date: commentElement1.createdAt, + user: commentElement1.user, + html: commentElement1.htmlText + }) + ); + expect(history[3]).toEqual( expect.objectContaining({ type: ReviewHistoryType.Diff, date: changelogElement.creationDate, diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerRenderer.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerRenderer.tsx index cf2b54a7e6e..34078e7b123 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerRenderer.tsx @@ -63,13 +63,13 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) { {translate('hotspot.status', hotspot.resolution || hotspot.status)} - {hotspot.assignee && hotspot.assignee.name && ( + {hotspot.assigneeUser && hotspot.assigneeUser.name && ( <> {translate('assigned_to')}: - {hotspot.assignee.active - ? hotspot.assignee.name - : translateWithParameters('user.x_deleted', hotspot.assignee.name)} + {hotspot.assigneeUser.active + ? hotspot.assigneeUser.name + : translateWithParameters('user.x_deleted', hotspot.assigneeUser.name)} )} diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerReviewHistoryTab.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerReviewHistoryTab.tsx index de1fee9b3c2..2dc57fb8770 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerReviewHistoryTab.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerReviewHistoryTab.tsx @@ -17,6 +17,8 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + +import { sanitize } from 'dompurify'; import * as React from 'react'; import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; import DateTimeFormatter from '../../../components/intl/DateTimeFormatter'; @@ -34,9 +36,9 @@ export default function HotspotViewerReviewHistoryTab(props: HotspotViewerReview return ( <> {history.map((elt, i) => ( - + {i > 0 &&
} -
+
{elt.user.name && ( <> @@ -56,6 +58,11 @@ export default function HotspotViewerReviewHistoryTab(props: HotspotViewerReview {translate('hotspots.tabs.review_history.created')} )} + {elt.type === ReviewHistoryType.Comment && ( + + {translate('hotspots.tabs.review_history.comment')} + + )} - )} @@ -64,14 +71,18 @@ export default function HotspotViewerReviewHistoryTab(props: HotspotViewerReview {elt.type === ReviewHistoryType.Diff && elt.diffs && (
- {elt.diffs.map(diff => ( - + {elt.diffs.map((diff, i) => ( + ))}
)} + + {elt.type === ReviewHistoryType.Comment && elt.html && ( +
+ )}
))} diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerTabs.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerTabs.tsx index de17ec8ffad..a52facf28bf 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerTabs.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerTabs.tsx @@ -84,10 +84,10 @@ export default function HotspotViewerTabs(props: HotspotViewerTabsProps) { selected={currentTabKey} tabs={tabs} /> -
+
{typeof currentTab.content === 'string' ? (
) : ( diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerRenderer-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerRenderer-test.tsx index 7de5b90f26e..68820dd230e 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerRenderer-test.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerRenderer-test.tsx @@ -31,12 +31,12 @@ it('should render correctly', () => { 'unassigned' ); expect( - shallowRender({ hotspot: mockHotspot({ assignee: mockUser({ active: false }) }) }) + shallowRender({ hotspot: mockHotspot({ assigneeUser: mockUser({ active: false }) }) }) ).toMatchSnapshot('deleted assignee'); expect( shallowRender({ hotspot: mockHotspot({ - assignee: mockUser({ name: undefined, login: 'assignee_login' }) + assigneeUser: mockUser({ name: undefined, login: 'assignee_login' }) }) }) ).toMatchSnapshot('assignee without name'); diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerReviewHistoryTab-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerReviewHistoryTab-test.tsx index e73571cfcc5..b724745ab7e 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerReviewHistoryTab-test.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerReviewHistoryTab-test.tsx @@ -44,6 +44,10 @@ function shallowRender(props?: Partial) { { key: 'test', oldValue: 'old', newValue: 'new' }, { key: 'test-1', oldValue: 'old-1', newValue: 'new-1' } ] + }), + mockHotspotReviewHistoryElement({ + type: ReviewHistoryType.Comment, + html: 'bold text' }) ]} {...props} diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainer-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainer-test.tsx.snap index adcc51f65c5..3bd6adbc45e 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainer-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainer-test.tsx.snap @@ -13,19 +13,22 @@ exports[`should render correctly 1`] = ` highlightedSymbols={Array []} hotspot={ Object { - "assignee": Object { + "assignee": "assignee", + "assigneeUser": Object { "active": true, "local": true, - "login": "john.doe", + "login": "assignee", "name": "John Doe", }, - "author": Object { + "author": "author", + "authorUser": Object { "active": true, "local": true, - "login": "john.doe", + "login": "author", "name": "John Doe", }, "changelog": Array [], + "comment": Array [], "component": Object { "breadcrumbs": Array [], "key": "my-project", @@ -90,6 +93,20 @@ exports[`should render correctly 1`] = ` "startOffset": 26, }, "updateDate": "2013-05-13T17:55:42+0200", + "users": Array [ + Object { + "active": true, + "local": true, + "login": "assignee", + "name": "John Doe", + }, + Object { + "active": true, + "local": true, + "login": "author", + "name": "John Doe", + }, + ], } } loading={true} diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainerRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainerRenderer-test.tsx.snap index e4fe72741f2..b169213e30e 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainerRenderer-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainerRenderer-test.tsx.snap @@ -137,19 +137,22 @@ exports[`should render correctly: with sourcelines 1`] = ` index={0} issue={ Object { - "assignee": Object { + "assignee": "assignee", + "assigneeUser": Object { "active": true, "local": true, - "login": "john.doe", + "login": "assignee", "name": "John Doe", }, - "author": Object { + "author": "author", + "authorUser": Object { "active": true, "local": true, - "login": "john.doe", + "login": "author", "name": "John Doe", }, "changelog": Array [], + "comment": Array [], "component": Object { "breadcrumbs": Array [], "key": "my-project", @@ -214,6 +217,20 @@ exports[`should render correctly: with sourcelines 1`] = ` "startOffset": 26, }, "updateDate": "2013-05-13T17:55:42+0200", + "users": Array [ + Object { + "active": true, + "local": true, + "login": "assignee", + "name": "John Doe", + }, + Object { + "active": true, + "local": true, + "login": "author", + "name": "John Doe", + }, + ], } } issuesByLine={Object {}} diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerRenderer-test.tsx.snap index 51794f6d687..6bc661b2507 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerRenderer-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerRenderer-test.tsx.snap @@ -59,19 +59,22 @@ exports[`should render correctly 1`] = ` @@ -285,19 +319,22 @@ exports[`should render correctly: anonymous user 1`] = ` @@ -500,19 +568,22 @@ exports[`should render correctly: assignee without name 1`] = ` @@ -726,19 +828,22 @@ exports[`should render correctly: deleted assignee 1`] = ` @@ -944,18 +1080,37 @@ exports[`should render correctly: unassigned 1`] = ` > hotspot.status.FIXED + + assigned_to + : + + + John Doe +
@@ -1027,13 +1196,21 @@ exports[`should render correctly: unassigned 1`] = ` hotspot={ Object { "assignee": undefined, - "author": Object { + "assigneeUser": Object { "active": true, "local": true, - "login": "john.doe", + "login": "assignee", + "name": "John Doe", + }, + "author": "author", + "authorUser": Object { + "active": true, + "local": true, + "login": "author", "name": "John Doe", }, "changelog": Array [], + "comment": Array [], "component": Object { "breadcrumbs": Array [], "key": "my-project", @@ -1098,6 +1275,20 @@ exports[`should render correctly: unassigned 1`] = ` "startOffset": 26, }, "updateDate": "2013-05-13T17:55:42+0200", + "users": Array [ + Object { + "active": true, + "local": true, + "login": "assignee", + "name": "John Doe", + }, + Object { + "active": true, + "local": true, + "login": "author", + "name": "John Doe", + }, + ], } } /> @@ -1125,19 +1316,22 @@ exports[`should render correctly: user logged in 1`] = ` diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerReviewHistoryTab-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerReviewHistoryTab-test.tsx.snap index c51db022df0..a417fe2f20d 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerReviewHistoryTab-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerReviewHistoryTab-test.tsx.snap @@ -2,7 +2,9 @@ exports[`should render correctly 1`] = ` -
+
@@ -31,7 +33,9 @@ exports[`should render correctly 1`] = `

-
+
@@ -59,7 +63,9 @@ exports[`should render correctly 1`] = `

-
+
@@ -69,7 +75,9 @@ exports[`should render correctly 1`] = `

-
+
@@ -101,7 +109,7 @@ exports[`should render correctly 1`] = ` "oldValue": "old", } } - key="test-old-new" + key="0" />
+
+
+
+ + + John Doe + + + hotspots.tabs.review_history.comment + + + - + + +
+
bold text", + } + } + /> +
`; diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerTabs-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerTabs-test.tsx.snap index 7262c691e11..9b33f4ab30b 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerTabs-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerTabs-test.tsx.snap @@ -26,7 +26,8 @@ exports[`should render correctly: empty tab 1`] = ` "type": 0, "user": Object { "active": true, - "avatar": undefined, + "local": true, + "login": "author", "name": "John Doe", }, }, @@ -49,10 +50,10 @@ exports[`should render correctly: empty tab 1`] = ` } />
This a strong message about vulnerability !

", @@ -94,7 +95,8 @@ exports[`should render correctly: fix 1`] = ` "type": 0, "user": Object { "active": true, - "avatar": undefined, + "local": true, + "login": "author", "name": "John Doe", }, }, @@ -117,10 +119,10 @@ exports[`should render correctly: fix 1`] = ` } />
This a strong message about fixing !

", @@ -164,7 +166,8 @@ exports[`should render correctly: review 1`] = ` "type": 0, "user": Object { "active": true, - "avatar": undefined, + "local": true, + "login": "author", "name": "John Doe", }, }, @@ -187,7 +190,7 @@ exports[`should render correctly: review 1`] = ` } />
This a strong message about risk !

", @@ -307,7 +312,8 @@ exports[`should render correctly: vulnerability 1`] = ` "type": 0, "user": Object { "active": true, - "avatar": undefined, + "local": true, + "login": "author", "name": "John Doe", }, }, @@ -330,10 +336,10 @@ exports[`should render correctly: vulnerability 1`] = ` } />
This a strong message about vulnerability !

", diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/utils.ts b/server/sonar-web/src/main/js/apps/securityHotspots/utils.ts index bed8333ed12..7d1de26d485 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/utils.ts +++ b/server/sonar-web/src/main/js/apps/securityHotspots/utils.ts @@ -89,27 +89,40 @@ export function getHotspotReviewHistory(hotspot: Hotspot): ReviewHistoryElement[ type: ReviewHistoryType.Creation, date: hotspot.creationDate, user: { - avatar: hotspot.author.avatar, - name: hotspot.author.name || hotspot.author.login, - active: hotspot.author.active + ...hotspot.authorUser, + name: hotspot.authorUser.name || hotspot.authorUser.login } }); } - if (hotspot.changelog) { + if (hotspot.changelog && hotspot.changelog.length > 0) { history.push( ...hotspot.changelog.map(log => ({ type: ReviewHistoryType.Diff, date: log.creationDate, user: { + active: log.isUserActive, avatar: log.avatar, - name: log.userName || log.user, - active: log.isUserActive + name: log.userName || log.user }, diffs: log.diffs })) ); } + if (hotspot.comment && hotspot.comment.length > 0) { + history.push( + ...hotspot.comment.map(comment => ({ + type: ReviewHistoryType.Comment, + date: comment.createdAt, + user: { + ...comment.user, + name: comment.user.name || comment.user.login + }, + html: comment.htmlText + })) + ); + } + return sortBy(history, elt => elt.date); } diff --git a/server/sonar-web/src/main/js/helpers/mocks/security-hotspots.ts b/server/sonar-web/src/main/js/helpers/mocks/security-hotspots.ts index 4351dc56ecb..a532fdb48a0 100644 --- a/server/sonar-web/src/main/js/helpers/mocks/security-hotspots.ts +++ b/server/sonar-web/src/main/js/helpers/mocks/security-hotspots.ts @@ -50,10 +50,15 @@ export function mockRawHotspot(overrides: Partial = {}): RawHotspot } export function mockHotspot(overrides?: Partial): Hotspot { + const assigneeUser = mockUser({ login: 'assignee' }); + const authorUser = mockUser({ login: 'author' }); return { - assignee: mockUser(), - author: mockUser(), + assignee: 'assignee', + assigneeUser, + author: 'author', + authorUser, changelog: [], + comment: [], component: mockComponent({ qualifier: ComponentQualifier.File }), creationDate: '2013-05-13T17:55:41+0200', key: '01fc972e-2a3c-433e-bcae-0bd7f88f5123', @@ -70,6 +75,7 @@ export function mockHotspot(overrides?: Partial): Hotspot { endOffset: 83 }, updateDate: '2013-05-13T17:55:42+0200', + users: [assigneeUser, authorUser], ...overrides }; } diff --git a/server/sonar-web/src/main/js/types/security-hotspots.ts b/server/sonar-web/src/main/js/types/security-hotspots.ts index a7e3536f414..29a12a207e3 100644 --- a/server/sonar-web/src/main/js/types/security-hotspots.ts +++ b/server/sonar-web/src/main/js/types/security-hotspots.ts @@ -69,9 +69,12 @@ export interface RawHotspot { } export interface Hotspot { - assignee?: Pick; - author: Pick; - changelog?: T.IssueChangelog[]; + assignee?: string; + assigneeUser?: T.UserBase; + author: string; + authorUser: T.UserBase; + changelog: T.IssueChangelog[]; + comment: HotspotComment[]; component: T.Component; creationDate: string; key: string; @@ -83,6 +86,7 @@ export interface Hotspot { status: string; textRange: T.TextRange; updateDate: string; + users: T.UserBase[]; } export interface HotspotUpdateFields { @@ -104,16 +108,28 @@ export interface HotspotRule { vulnerabilityProbability: RiskExposure; } +export interface HotspotComment { + key: string; + htmlText: string; + markdown: string; + updatable: boolean; + createdAt: string; + login: string; + user: T.UserBase; +} + export interface ReviewHistoryElement { type: ReviewHistoryType; date: string; user: Pick; diffs?: T.IssueChangelogDiff[]; + html?: string; } export enum ReviewHistoryType { Creation, - Diff + Diff, + Comment } export interface HotspotSearchResponse { diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 8c26488e343..4a6b30d3984 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -661,6 +661,7 @@ hotspots.tabs.vulnerability_description=Are you vulnerable? hotspots.tabs.fix_recommendations=How can you fix it? hotspots.tabs.review_history=Review history hotspots.tabs.review_history.created=created Security Hotspot +hotspots.tabs.review_history.comment=added a comment hotspot.change_status.REVIEWED=Change status hotspot.change_status.TO_REVIEW=Review Hotspot -- 2.39.5