commit 31825d8f82cda8cf65c4faef8216cbed4268e5af Author: Ben Rohlfs Date: Fri Oct 2 18:08:04 2020 +0200 Change attention set logic to take unresolved state into account Change-Id: Iea08d3460c949c945a790c3316caf95e6fefe6b1 diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts index 87d443b..ad95b2a 100644 --- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts +++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts @@ -41,7 +41,7 @@ import { NumericChangeId, } from '../../../types/common'; import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api'; -import {CommentThread} from '../../diff/gr-comment-api/gr-comment-api'; +import {CommentThread} from '../../../utils/comment-util'; import {hasOwnProperty} from '../../../utils/common-util'; const PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?(?:P|p)atch (?:S|s)et \d+:\s*(.*)/; diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js index bed78df..e85f9e4 100644 --- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js +++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js @@ -411,6 +411,7 @@ class GrMessagesList extends KeyboardShortcutMixin( const labels = labelRecord.base; if (!labels) { return extremes; } for (const key of Object.keys(labels)) { + // TODO(TS): Let's use label-util! if (!labels[key] || !labels[key].values) { continue; } const values = Object.keys(labels[key].values) .map(v => parseInt(v, 10)); diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts index 0732c0f..a2a75d0 100644 --- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts +++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts @@ -102,12 +102,14 @@ import { assertNever, containsAll, } from '../../../utils/common-util'; -import {CommentThread, isDraft} from '../../diff/gr-comment-api/gr-comment-api'; +import {CommentThread} from '../../../utils/comment-util'; import {GrTextarea} from '../../shared/gr-textarea/gr-textarea'; import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip'; import {GrOverlay} from '../../shared/gr-overlay/gr-overlay'; import {GrStorage, StorageLocation} from '../../shared/gr-storage/gr-storage'; import {isAttentionSetEnabled} from '../../../utils/attention-set-util'; +import {CODE_REVIEW, getMaxAccounts} from '../../../utils/label-util'; +import {isUnresolved} from '../../../utils/comment-util'; const STORAGE_DEBOUNCE_INTERVAL_MS = 400; @@ -937,7 +939,8 @@ export class GrReplyDialog extends KeyboardShortcutMixin( '_reviewers.*', '_ccs.*', 'change', - 'draftCommentThreads' + 'draftCommentThreads', + '_includeComments' ) _computeNewAttention( currentUser?: AccountInfo, @@ -947,17 +950,22 @@ export class GrReplyDialog extends KeyboardShortcutMixin( >, _?: PolymerDeepPropertyChange, change?: ChangeInfo, - draftCommentThreads?: CommentThread[] + draftCommentThreads?: CommentThread[], + includeComments?: boolean ) { if ( currentUser === undefined || currentUser._account_id === undefined || reviewers === undefined || change === undefined || - draftCommentThreads === undefined + draftCommentThreads === undefined || + includeComments === undefined ) { return; } + // The draft comments are only relevant for the attention set as long as the + // user actually plans to publish their drafts. + draftCommentThreads = includeComments ? draftCommentThreads : []; this._currentAttentionSet = new Set( Object.keys(change.attention_set || {}).map( id => parseInt(id) as AccountId @@ -993,8 +1001,9 @@ export class GrReplyDialog extends KeyboardShortcutMixin( } } else { // The only reason for adding someone to the attention set for merged or - // abandoned changes is that someone adds a new comment thread. - if (change.owner && this._containsNewCommentThread(draftCommentThreads)) { + // abandoned changes is that someone makes a comment thread unresolved. + const hasUnresolvedDraft = draftCommentThreads.some(isUnresolved); + if (change.owner && hasUnresolvedDraft) { // A change owner must have an _account_id. newAttention.add(change.owner._account_id!); } @@ -1033,25 +1042,23 @@ export class GrReplyDialog extends KeyboardShortcutMixin( } _computeCommentAccounts(threads: CommentThread[]) { + const crLabel = this.change?.labels?.[CODE_REVIEW]; + const maxCrVoteAccountIds = getMaxAccounts(crLabel).map(a => a._account_id); const accountIds = new Set(); threads.forEach(thread => { + const unresolved = isUnresolved(thread); thread.comments.forEach(comment => { if (comment.author) { // A comment author must have an _account_id. - accountIds.add(comment.author._account_id!); + const authorId = comment.author._account_id!; + const hasGivenMaxReviewVote = maxCrVoteAccountIds.includes(authorId); + if (unresolved || !hasGivenMaxReviewVote) accountIds.add(authorId); } }); }); return accountIds; } - _containsNewCommentThread(threads: CommentThread[]) { - return threads.some( - thread => - !!thread.comments && !!thread.comments[0] && isDraft(thread.comments[0]) - ); - } - _isNewAttentionEmpty( config?: ServerInfo, currentAttentionSet?: Set, diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js index 301715c..2e05ed7 100644 --- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js +++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js @@ -203,7 +203,8 @@ suite('gr-reply-dialog tests', () => { }); function checkComputeAttention(status, userId, reviewerIds, ownerId, - attSetIds, replyToIds, expectedIds, uploaderId, hasDraft) { + attSetIds, replyToIds, expectedIds, uploaderId, hasDraft, + includeComments = true) { const user = {_account_id: userId}; const reviewers = {base: reviewerIds.map(id => { return {_account_id: id}; @@ -212,7 +213,7 @@ suite('gr-reply-dialog tests', () => { {comments: []}, ]; if (hasDraft) { - draftThreads[0].comments.push({__draft: true}); + draftThreads[0].comments.push({__draft: true, unresolved: true}); } replyToIds.forEach(id => draftThreads[0].comments.push({ author: {_account_id: id}, @@ -230,7 +231,8 @@ suite('gr-reply-dialog tests', () => { element.change = change; element._reviewers = reviewers.base; flush(); - element._computeNewAttention(user, reviewers, [], change, draftThreads); + element._computeNewAttention( + user, reviewers, [], change, draftThreads, includeComments); assert.sameMembers([...element._newAttentionSet], expectedIds); } @@ -262,6 +264,8 @@ suite('gr-reply-dialog tests', () => { checkComputeAttention('MERGED', null, [], 999, [], [], []); checkComputeAttention('MERGED', 1, [], 999, [], [], []); checkComputeAttention('MERGED', 1, [], 999, [], [], [999], undefined, true); + checkComputeAttention( + 'MERGED', 1, [], 999, [], [], [], undefined, true, false); checkComputeAttention('MERGED', 1, [], 999, [1], [], []); checkComputeAttention('MERGED', 1, [22], 999, [], [], []); checkComputeAttention('MERGED', 1, [22], 999, [22], [], [22]); @@ -300,6 +304,45 @@ suite('gr-reply-dialog tests', () => { assert.sameMembers(compute([999], [7, 123, 999]), [7, 123]); }); + test('_computeCommentAccounts', () => { + element.change = { + labels: { + 'Code-Review': { + all: [ + {_account_id: 1, value: 0}, + {_account_id: 2, value: 1}, + {_account_id: 3, value: 2}, + ], + values: { + '-2': 'Do not submit', + '-1': 'I would prefer that you didnt submit this', + ' 0': 'No score', + '+1': 'Looks good to me, but someone else must approve', + '+2': 'Looks good to me, approved', + }, + }, + }, + }; + const threads = [ + { + comments: [ + {author: {_account_id: 1}, unresolved: false}, + {author: {_account_id: 2}, unresolved: true}, + ], + }, + { + comments: [ + {author: {_account_id: 3}, unresolved: false}, + {author: {_account_id: 4}, unresolved: false}, + ], + }, + ]; + const actualAccounts = [...element._computeCommentAccounts(threads)]; + // Account 3 is not included, because the comment is resolved *and* they + // have given the highest possible vote on the Code-Review label. + assert.sameMembers(actualAccounts, [1, 2, 4]); + }); + test('toggle resolved checkbox', done => { // Async tick is needed because iron-selector content is distributed and // distributed content requires an observer to be set up. diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts index 4b02075..7585d16 100644 --- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts +++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts @@ -27,11 +27,7 @@ import {parseDate} from '../../../utils/date-util'; import {NO_THREADS_MSG} from '../../../constants/messages'; import {CommentSide, SpecialFilePath} from '../../../constants/constants'; import {customElement, observe, property} from '@polymer/decorators'; -import { - CommentThread, - isDraft, - UIRobot, -} from '../../diff/gr-comment-api/gr-comment-api'; +import {CommentThread, isDraft, UIRobot} from '../../../utils/comment-util'; import {PolymerSpliceChange} from '@polymer/polymer/interfaces'; import {ChangeInfo} from '../../../types/common'; diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts index 675a048..2236db2 100644 --- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts +++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts @@ -40,7 +40,7 @@ import { import {GrOverlay} from '../../shared/gr-overlay/gr-overlay'; import {CommentEventDetail} from '../../shared/gr-comment/gr-comment'; import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api'; -import {isRobot} from '../gr-comment-api/gr-comment-api'; +import {isRobot} from '../../../utils/comment-util'; export interface GrApplyFixDialog { $: { diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts index deecdbf..33c1346 100644 --- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts +++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts @@ -19,7 +19,6 @@ import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-l import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin'; import {PolymerElement} from '@polymer/polymer/polymer-element'; import {htmlTemplate} from './gr-comment-api_html'; -import {parseDate} from '../../../utils/date-util'; import { getParentIndex, isMergeParent, @@ -28,121 +27,33 @@ import { import {customElement, property} from '@polymer/decorators'; import { CommentBasics, - CommentInfo, ConfigInfo, ParentPatchSetNum, PatchRange, PatchSetNum, PathToRobotCommentsInfoMap, RobotCommentInfo, - Timestamp, UrlEncodedCommentId, NumericChangeId, } from '../../../types/common'; import {hasOwnProperty} from '../../../utils/common-util'; -import {CommentSide, Side} from '../../../constants/constants'; +import {CommentSide} from '../../../constants/constants'; import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api'; - -export interface DraftCommentProps { - __draft?: boolean; - __draftID?: string; - __date?: Date; -} - -export type DraftInfo = CommentBasics & DraftCommentProps; - -/** - * Each of the type implements or extends CommentBasics. - */ -export type Comment = DraftInfo | CommentInfo | RobotCommentInfo; - -export interface UIStateCommentProps { - // The `side` of the comment is PARENT or REVISION, but this is LEFT or RIGHT. - // TODO(TS): Remove the naming confusion of commentSide being of type of Side, - // but side being of type CommentSide. :-) - __commentSide?: Side; - // TODO(TS): Remove this. Seems to be exactly the same as `path`?? - __path?: string; - collapsed?: boolean; - // TODO(TS): Consider allowing this only for drafts. - __editing?: boolean; - __otherEditing?: boolean; -} - -export type UIDraft = DraftInfo & UIStateCommentProps; - -export type UIHuman = CommentInfo & UIStateCommentProps; - -export type UIRobot = RobotCommentInfo & UIStateCommentProps; - -export type UIComment = UIHuman | UIRobot | UIDraft; - -export type CommentMap = {[path: string]: boolean}; - -export function isRobot( - x: T | DraftInfo | RobotCommentInfo | undefined -): x is RobotCommentInfo { - return !!x && !!(x as RobotCommentInfo).robot_id; -} - -export function isDraft( - x: T | UIDraft | undefined -): x is UIDraft { - return !!x && !!(x as UIDraft).__draft; -} - -export interface PatchSetFile { - path: string; - basePath?: string; - patchNum?: PatchSetNum; -} - -export interface PatchNumOnly { - patchNum: PatchSetNum; -} - -export function isPatchSetFile( - x: PatchSetFile | PatchNumOnly -): x is PatchSetFile { - return !!(x as PatchSetFile).path; -} - -interface SortableComment { - __draft?: boolean; - __date?: Date; - updated?: Timestamp; - id?: UrlEncodedCommentId; -} - -export function sortComments(comments: T[]): T[] { - return comments.slice(0).sort((c1, c2) => { - const d1 = !!c1.__draft; - const d2 = !!c2.__draft; - if (d1 !== d2) return d1 ? 1 : -1; - - const date1 = (c1.updated && parseDate(c1.updated)) || c1.__date; - const date2 = (c2.updated && parseDate(c2.updated)) || c2.__date; - const dateDiff = date1!.valueOf() - date2!.valueOf(); - if (dateDiff !== 0) return dateDiff; - - const id1 = c1.id ?? ''; - const id2 = c2.id ?? ''; - return id1.localeCompare(id2); - }); -} - -export interface CommentThread { - comments: UIComment[]; - patchNum?: PatchSetNum; - path: string; - // TODO(TS): It would be nice to use LineNumber here, but the comment thread - // element actually relies on line to be undefined for file comments. Be - // aware of element attribute getters and setters, if you try to refactor - // this. :-) Still worthwhile to do ... - line?: number; - rootId: UrlEncodedCommentId; - commentSide?: CommentSide; -} +import { + Comment, + CommentMap, + CommentThread, + DraftInfo, + isPatchSetFile, + isUnresolved, + PatchNumOnly, + PatchSetFile, + sortComments, + UIComment, + UIDraft, + UIHuman, + UIRobot, +} from '../../../utils/comment-util'; export type CommentIdToCommentThreadMap = { [urlEncodedCommentId: string]: CommentThread; @@ -560,15 +471,8 @@ export class ChangeComments { } comments = comments.concat(drafts); - const threads = this.getCommentThreads(sortComments(comments)); - - const unresolvedThreads = threads.filter( - thread => - thread.comments.length && - thread.comments[thread.comments.length - 1].unresolved - ); - + const unresolvedThreads = threads.filter(isUnresolved); return unresolvedThreads.length; } diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts index d33e1c1..951a56c 100644 --- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts +++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts @@ -36,9 +36,9 @@ import { isDraft, PatchSetFile, sortComments, - TwoSidesComments, UIComment, -} from '../gr-comment-api/gr-comment-api'; +} from '../../../utils/comment-util'; +import {TwoSidesComments} from '../gr-comment-api/gr-comment-api'; import {customElement, observe, property} from '@polymer/decorators'; import { CommitRange, diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js index 3c12a50..c772d51 100644 --- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js +++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js @@ -20,7 +20,7 @@ import './gr-diff-host.js'; import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js'; import {GerritNav} from '../../core/gr-navigation/gr-navigation.js'; import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js'; -import {sortComments} from '../gr-comment-api/gr-comment-api.js'; +import {sortComments} from '../../../utils/comment-util.js'; import {Side} from '../../../constants/constants.js'; const basicFixture = fixtureFromElement('gr-diff-host'); diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts index fe6a0fd..de283d8 100644 --- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts +++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts @@ -34,7 +34,7 @@ import { UIComment, UIDraft, UIRobot, -} from '../../diff/gr-comment-api/gr-comment-api'; +} from '../../../utils/comment-util'; import {GerritNav} from '../../core/gr-navigation/gr-navigation'; import {appContext} from '../../../services/app-context'; import {CommentSide, Side, SpecialFilePath} from '../../../constants/constants'; diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js index 52264ec..1833b73 100644 --- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js +++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js @@ -19,7 +19,7 @@ import '../../../test/common-test-setup-karma.js'; import './gr-comment-thread.js'; import {GerritNav} from '../../core/gr-navigation/gr-navigation.js'; import {SpecialFilePath} from '../../../constants/constants.js'; -import {sortComments} from '../../diff/gr-comment-api/gr-comment-api.js'; +import {sortComments} from '../../../utils/comment-util.js'; const basicFixture = fixtureFromElement('gr-comment-thread'); diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts index d68da94..9a1d789 100644 --- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts +++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts @@ -58,7 +58,7 @@ import { UIComment, UIDraft, UIRobot, -} from '../../diff/gr-comment-api/gr-comment-api'; +} from '../../../utils/comment-util'; const STORAGE_DEBOUNCE_INTERVAL = 400; const TOAST_DEBOUNCE_INTERVAL = 200; diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts index b6e664e..a10f931 100644 --- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts +++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts @@ -40,6 +40,7 @@ import { } from '../../../types/common'; import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api'; import {GrButton} from '../gr-button/gr-button'; +import {getVotingRange} from '../../../utils/label-util'; export interface GrLabelInfo { $: { @@ -125,23 +126,20 @@ export class GrLabelInfo extends GestureEventListeners( const votes = (labelInfo.all || []).sort( (a, b) => (a.value || 0) - (b.value || 0) ); - const values = Object.keys(labelInfo.values || {}); + const votingRange = getVotingRange(labelInfo); for (const label of votes) { if (label.value && label.value !== labelInfo.default_value) { let labelClassName; let labelValPrefix = ''; if (label.value > 0) { labelValPrefix = '+'; - if ( - parseInt(`${label.value}`, 10) === - parseInt(values[values.length - 1], 10) - ) { + if (label.value === votingRange.max) { labelClassName = LabelClassName.MAX; } else { labelClassName = LabelClassName.POSITIVE; } } else if (label.value < 0) { - if (parseInt(`${label.value}`, 10) === parseInt(values[0], 10)) { + if (label.value === votingRange.min) { labelClassName = LabelClassName.MIN; } else { labelClassName = LabelClassName.NEGATIVE; diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts index caa5f7f..e71f09b 100644 --- a/polygerrit-ui/app/types/common.ts +++ b/polygerrit-ui/app/types/common.ts @@ -145,7 +145,8 @@ export type LabelNameToValueMap = {[labelName: string]: string[]}; export type LabelValueToDescriptionMap = {[labelValue: string]: string}; /** - * The LabelInfo entity contains information about a label on a change, always corresponding to the current patch set. + * The LabelInfo entity contains information about a label on a change, always + * corresponding to the current patch set. * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#label-info */ export type LabelInfo = QuickLabelInfo | DetailedLabelInfo; @@ -164,12 +165,24 @@ export interface QuickLabelInfo extends LabelCommonInfo { default_value?: number; } +/** + * LabelInfo when DETAILED_LABELS are requested. + * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#_fields_set_by_code_detailed_labels_code + */ export interface DetailedLabelInfo extends LabelCommonInfo { - all?: ApprovalInfo[]; - values?: LabelValueToDescriptionMap; // A map of all values that are allowed for this label + // Docs claim that 'all' is optional, but it is actually always set. + all: ApprovalInfo[]; + // Docs claim that 'values' is optional, but it is actually always set. + values: LabelValueToDescriptionMap; // A map of all values that are allowed for this label default_value?: number; } +export function isDetailedLabelInfo( + label: LabelInfo +): label is DetailedLabelInfo { + return !!(label as DetailedLabelInfo).values; +} + // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-input export interface ContributorAgreementInput { name?: string; diff --git a/polygerrit-ui/app/utils/attention-set-util_test.js b/polygerrit-ui/app/utils/attention-set-util_test.js index 0273a33..71735d5 100644 --- a/polygerrit-ui/app/utils/attention-set-util_test.js +++ b/polygerrit-ui/app/utils/attention-set-util_test.js @@ -20,6 +20,13 @@ import { hasAttention, getReason, } from './attention-set-util.js'; +const KERMIT = { + email: 'kermit@gmail.com', + username: 'kermit', + name: 'Kermit The Frog', + _account_id: '31415926535', +}; + suite('attention-set-util', () => { test('hasAttention', () => { const config = { @@ -32,14 +39,8 @@ suite('attention-set-util', () => { }, }, }; - const account = { - email: 'kermit@gmail.com', - username: 'kermit', - name: 'Kermit The Frog', - _account_id: '31415926535', - }; - assert.isTrue(hasAttention(config, account, change)); + assert.isTrue(hasAttention(config, KERMIT, change)); }); test('getReason', () => { @@ -50,13 +51,7 @@ suite('attention-set-util', () => { }, }, }; - const account = { - email: 'kermit@gmail.com', - username: 'kermit', - name: 'Kermit The Frog', - _account_id: '31415926535', - }; - assert.equal(getReason(account, change), 'a good reason'); + assert.equal(getReason(KERMIT, change), 'a good reason'); }); }); diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts new file mode 100644 index 0000000..e598e2b --- /dev/null +++ b/polygerrit-ui/app/utils/comment-util.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright (C) 2020 The Android Open Source Project + * + * 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. + */ +import { + CommentBasics, + CommentInfo, + PatchSetNum, + RobotCommentInfo, + Timestamp, + UrlEncodedCommentId, +} from '../types/common'; +import {CommentSide, Side} from '../constants/constants'; +import {parseDate} from './date-util'; + +export interface DraftCommentProps { + __draft?: boolean; + __draftID?: string; + __date?: Date; +} + +export type DraftInfo = CommentBasics & DraftCommentProps; + +/** + * Each of the type implements or extends CommentBasics. + */ +export type Comment = DraftInfo | CommentInfo | RobotCommentInfo; + +export interface UIStateCommentProps { + // The `side` of the comment is PARENT or REVISION, but this is LEFT or RIGHT. + // TODO(TS): Remove the naming confusion of commentSide being of type of Side, + // but side being of type CommentSide. :-) + __commentSide?: Side; + // TODO(TS): Remove this. Seems to be exactly the same as `path`?? + __path?: string; + collapsed?: boolean; + // TODO(TS): Consider allowing this only for drafts. + __editing?: boolean; + __otherEditing?: boolean; +} + +export type UIDraft = DraftInfo & UIStateCommentProps; + +export type UIHuman = CommentInfo & UIStateCommentProps; + +export type UIRobot = RobotCommentInfo & UIStateCommentProps; + +export type UIComment = UIHuman | UIRobot | UIDraft; + +export type CommentMap = {[path: string]: boolean}; + +export function isRobot( + x: T | DraftInfo | RobotCommentInfo | undefined +): x is RobotCommentInfo { + return !!x && !!(x as RobotCommentInfo).robot_id; +} + +export function isDraft( + x: T | UIDraft | undefined +): x is UIDraft { + return !!x && !!(x as UIDraft).__draft; +} + +export interface PatchSetFile { + path: string; + basePath?: string; + patchNum?: PatchSetNum; +} + +export interface PatchNumOnly { + patchNum: PatchSetNum; +} + +export function isPatchSetFile( + x: PatchSetFile | PatchNumOnly +): x is PatchSetFile { + return !!(x as PatchSetFile).path; +} + +interface SortableComment { + __draft?: boolean; + __date?: Date; + updated?: Timestamp; + id?: UrlEncodedCommentId; +} + +export function sortComments(comments: T[]): T[] { + return comments.slice(0).sort((c1, c2) => { + const d1 = !!c1.__draft; + const d2 = !!c2.__draft; + if (d1 !== d2) return d1 ? 1 : -1; + + const date1 = (c1.updated && parseDate(c1.updated)) || c1.__date; + const date2 = (c2.updated && parseDate(c2.updated)) || c2.__date; + const dateDiff = date1!.valueOf() - date2!.valueOf(); + if (dateDiff !== 0) return dateDiff; + + const id1 = c1.id ?? ''; + const id2 = c2.id ?? ''; + return id1.localeCompare(id2); + }); +} + +export interface CommentThread { + comments: UIComment[]; + patchNum?: PatchSetNum; + path: string; + // TODO(TS): It would be nice to use LineNumber here, but the comment thread + // element actually relies on line to be undefined for file comments. Be + // aware of element attribute getters and setters, if you try to refactor + // this. :-) Still worthwhile to do ... + line?: number; + rootId: UrlEncodedCommentId; + commentSide?: CommentSide; +} + +export function getLastComment(thread?: CommentThread): UIComment | undefined { + const len = thread?.comments.length; + return thread && len ? thread.comments[len - 1] : undefined; +} + +export function isUnresolved(thread?: CommentThread): boolean { + return !!getLastComment(thread)?.unresolved; +} + +export function isDraftThread(thread?: CommentThread): boolean { + return isDraft(getLastComment(thread)); +} diff --git a/polygerrit-ui/app/utils/comment-util_test.js b/polygerrit-ui/app/utils/comment-util_test.js new file mode 100644 index 0000000..ad19974 --- /dev/null +++ b/polygerrit-ui/app/utils/comment-util_test.js @@ -0,0 +1,34 @@ +/** + * @license + * Copyright (C) 2020 The Android Open Source Project + * + * 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. + */ + +import '../test/common-test-setup-karma.js'; +import { + isUnresolved, +} from './comment-util.js'; + +suite('comment-util', () => { + test('isUnresolved', () => { + assert.isFalse(isUnresolved(undefined)); + assert.isFalse(isUnresolved({comments: []})); + assert.isTrue(isUnresolved({comments: [{unresolved: true}]})); + assert.isFalse(isUnresolved({comments: [{unresolved: false}]})); + assert.isTrue(isUnresolved( + {comments: [{unresolved: false}, {unresolved: true}]})); + assert.isFalse(isUnresolved( + {comments: [{unresolved: true}, {unresolved: false}]})); + }); +}); diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts new file mode 100644 index 0000000..78151be --- /dev/null +++ b/polygerrit-ui/app/utils/label-util.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright (C) 2020 The Android Open Source Project + * + * 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. + */ +import { + ApprovalInfo, + isDetailedLabelInfo, + LabelInfo, + VotingRangeInfo, +} from '../types/common'; + +// Name of the standard Code-Review label. +export const CODE_REVIEW = 'Code-Review'; + +export function getVotingRange(label?: LabelInfo): VotingRangeInfo { + if (!label || !isDetailedLabelInfo(label)) return {min: 0, max: 0}; + const values = Object.keys(label.values).map(v => parseInt(v, 10)); + values.sort((a, b) => a - b); + if (!values.length) return {min: 0, max: 0}; + return {min: values[0], max: values[values.length - 1]}; +} + +export function getMaxAccounts(label?: LabelInfo): ApprovalInfo[] { + if (!label || !isDetailedLabelInfo(label)) return []; + const votingRange = getVotingRange(label); + return label.all.filter(account => account.value === votingRange.max); +} diff --git a/polygerrit-ui/app/utils/label-util_test.js b/polygerrit-ui/app/utils/label-util_test.js new file mode 100644 index 0000000..5bd48a1 --- /dev/null +++ b/polygerrit-ui/app/utils/label-util_test.js @@ -0,0 +1,49 @@ +/** + * @license + * Copyright (C) 2020 The Android Open Source Project + * + * 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. + */ + +import '../test/common-test-setup-karma.js'; +import { + getVotingRange, +} from './label-util.js'; + +suite('label-util', () => { + test('getVotingRange -1 to +1', () => { + const label = { + values: { + '-1': 'bad', + '0': 'neutral', + '+1': 'good', + }, + }; + const expectedRange = {min: -1, max: 1}; + assert.deepEqual(getVotingRange(label), expectedRange); + }); + + test('getVotingRange -2 to +2', () => { + const label = { + values: { + '-1': 'bad', + '+2': 'perfect', + '0': 'neutral', + '-2': 'blocking', + '+1': 'good', + }, + }; + const expectedRange = {min: -2, max: 2}; + assert.deepEqual(getVotingRange(label), expectedRange); + }); +});