| /** | 
 |  * @license | 
 |  * Copyright 2016 Google LLC | 
 |  * SPDX-License-Identifier: Apache-2.0 | 
 |  */ | 
 | import '../../admin/gr-create-change-dialog/gr-create-change-dialog'; | 
 | import '../../shared/gr-button/gr-button'; | 
 | import '../../shared/gr-dialog/gr-dialog'; | 
 | import '../../shared/gr-dropdown/gr-dropdown'; | 
 | import '../../shared/gr-icon/gr-icon'; | 
 | import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog'; | 
 | import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog'; | 
 | import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog'; | 
 | import '../gr-confirm-move-dialog/gr-confirm-move-dialog'; | 
 | import '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog'; | 
 | import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog'; | 
 | import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog'; | 
 | import '../../../styles/shared-styles'; | 
 | import {navigationToken} from '../../core/gr-navigation/gr-navigation'; | 
 | import {getAppContext} from '../../../services/app-context'; | 
 | import { | 
 |   CURRENT, | 
 |   hasEditBasedOnCurrentPatchSet, | 
 | } from '../../../utils/patch-set-util'; | 
 | import { | 
 |   changeIsOpen, | 
 |   isOwner, | 
 |   listChangesOptionsToHex, | 
 | } from '../../../utils/change-util'; | 
 | import { | 
 |   ChangeStatus, | 
 |   DraftsAction, | 
 |   HttpMethod, | 
 |   NotifyType, | 
 | } from '../../../constants/constants'; | 
 | import {TargetElement} from '../../../api/plugin'; | 
 | import { | 
 |   AccountInfo, | 
 |   ActionInfo, | 
 |   ActionNameToActionInfoMap, | 
 |   BranchName, | 
 |   ChangeActionDialog, | 
 |   ChangeInfo, | 
 |   CherryPickInput, | 
 |   CommentThread, | 
 |   CommitId, | 
 |   InheritedBooleanInfo, | 
 |   isDetailedLabelInfo, | 
 |   isQuickLabelInfo, | 
 |   LabelInfo, | 
 |   ListChangesOption, | 
 |   NumericChangeId, | 
 |   PatchSetNumber, | 
 |   RequestPayload, | 
 |   RevertSubmissionInfo, | 
 |   ReviewInput, | 
 |   RevisionInfo, | 
 | } from '../../../types/common'; | 
 | import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog'; | 
 | import {GrDialog} from '../../shared/gr-dialog/gr-dialog'; | 
 | import {GrCreateChangeDialog} from '../../admin/gr-create-change-dialog/gr-create-change-dialog'; | 
 | import {GrConfirmSubmitDialog} from '../gr-confirm-submit-dialog/gr-confirm-submit-dialog'; | 
 | import { | 
 |   ConfirmRevertEventDetail, | 
 |   GrConfirmRevertDialog, | 
 |   RevertType, | 
 | } from '../gr-confirm-revert-dialog/gr-confirm-revert-dialog'; | 
 | import {GrConfirmMoveDialog} from '../gr-confirm-move-dialog/gr-confirm-move-dialog'; | 
 | import {GrConfirmCherrypickDialog} from '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog'; | 
 | import {GrConfirmCherrypickConflictDialog} from '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog'; | 
 | import { | 
 |   ConfirmRebaseEventDetail, | 
 |   GrConfirmRebaseDialog, | 
 | } from '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog'; | 
 | import {GrButton} from '../../shared/gr-button/gr-button'; | 
 | import { | 
 |   GrChangeActionsElement, | 
 |   UIActionInfo, | 
 | } from '../../shared/gr-js-api-interface/gr-change-actions-js-api'; | 
 | import { | 
 |   fire, | 
 |   fireAlert, | 
 |   fireError, | 
 |   fireNoBubbleNoCompose, | 
 | } from '../../../utils/event-util'; | 
 | import { | 
 |   getApprovalInfo, | 
 |   getVotingRange, | 
 |   StandardLabels, | 
 | } from '../../../utils/label-util'; | 
 | import { | 
 |   ActionPriority, | 
 |   ActionType, | 
 |   ChangeActions, | 
 |   PrimaryActionKey, | 
 |   RevisionActions, | 
 | } from '../../../api/change-actions'; | 
 | import {ErrorCallback} from '../../../api/rest'; | 
 | import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown'; | 
 | import {resolve} from '../../../models/dependency'; | 
 | import {changeModelToken} from '../../../models/change/change-model'; | 
 | import {sharedStyles} from '../../../styles/shared-styles'; | 
 | import {css, html, LitElement, nothing, PropertyValues} from 'lit'; | 
 | import {customElement, query, state} from 'lit/decorators.js'; | 
 | import {ifDefined} from 'lit/directives/if-defined.js'; | 
 | import {assertIsDefined, queryAll, uuid} from '../../../utils/common-util'; | 
 | import {Interaction} from '../../../constants/reporting'; | 
 | import {rootUrl} from '../../../utils/url-util'; | 
 | import {createSearchUrl} from '../../../models/views/search'; | 
 | import {createChangeUrl} from '../../../models/views/change'; | 
 | import {storageServiceToken} from '../../../services/storage/gr-storage_impl'; | 
 | import {ShowRevisionActionsDetail} from '../../shared/gr-js-api-interface/gr-js-api-types'; | 
 | import {whenVisible} from '../../../utils/dom-util'; | 
 | import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader'; | 
 | import {modalStyles} from '../../../styles/gr-modal-styles'; | 
 | import {subscribe} from '../../lit/subscription-controller'; | 
 | import {userModelToken} from '../../../models/user/user-model'; | 
 | import {ParsedChangeInfo} from '../../../types/types'; | 
 | import {configModelToken} from '../../../models/config/config-model'; | 
 | import {readJSONResponsePayload} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper'; | 
 | import {commentsModelToken} from '../../../models/comments/comments-model'; | 
 | import {when} from 'lit/directives/when.js'; | 
 | import {ValidationOptionInfo} from '../../../api/rest-api'; | 
 |  | 
 | const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.'; | 
 | const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.'; | 
 | const ERR_REVISION_ACTIONS = 'Couldn’t load revision actions.'; | 
 |  | 
 | export enum LabelStatus { | 
 |   /** | 
 |    * This label provides what is necessary for submission. | 
 |    */ | 
 |   OK = 'OK', | 
 |   /** | 
 |    * This label prevents the change from being submitted. | 
 |    */ | 
 |   REJECT = 'REJECT', | 
 |   /** | 
 |    * The label may be set, but it's neither necessary for submission | 
 |    * nor does it block submission if set. | 
 |    */ | 
 |   MAY = 'MAY', | 
 |   /** | 
 |    * The label is required for submission, but has not been satisfied. | 
 |    */ | 
 |   NEED = 'NEED', | 
 |   /** | 
 |    * The label is required for submission, but is impossible to complete. | 
 |    * The likely cause is access has not been granted correctly by the | 
 |    * project owner or site administrator. | 
 |    */ | 
 |   IMPOSSIBLE = 'IMPOSSIBLE', | 
 |   OPTIONAL = 'OPTIONAL', | 
 | } | 
 |  | 
 | const ActionLoadingLabels: {[actionKey: string]: string} = { | 
 |   abandon: 'Abandoning...', | 
 |   cherrypick: 'Cherry-picking...', | 
 |   delete: 'Deleting...', | 
 |   move: 'Moving..', | 
 |   rebase: 'Rebasing...', | 
 |   restore: 'Restoring...', | 
 |   revert: 'Reverting...', | 
 |   submit: 'Submitting...', | 
 | }; | 
 |  | 
 | const ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_'; | 
 |  | 
 | interface QuickApproveUIActionInfo extends UIActionInfo { | 
 |   key: string; | 
 |   payload?: RequestPayload; | 
 | } | 
 |  | 
 | const QUICK_APPROVE_ACTION: QuickApproveUIActionInfo = { | 
 |   __key: 'review', | 
 |   __type: ActionType.CHANGE, | 
 |   enabled: true, | 
 |   key: 'review', | 
 |   label: 'Quick approve', | 
 |   method: HttpMethod.POST, | 
 | }; | 
 |  | 
 | function isQuickApproveAction( | 
 |   action: UIActionInfo | 
 | ): action is QuickApproveUIActionInfo { | 
 |   return (action as QuickApproveUIActionInfo).key === QUICK_APPROVE_ACTION.key; | 
 | } | 
 |  | 
 | const DOWNLOAD_ACTION: UIActionInfo = { | 
 |   enabled: true, | 
 |   label: 'Download patch', | 
 |   title: 'Open download dialog', | 
 |   __key: 'download', | 
 |   __primary: false, | 
 |   __type: ActionType.REVISION, | 
 | }; | 
 |  | 
 | const INCLUDED_IN_ACTION: UIActionInfo = { | 
 |   enabled: true, | 
 |   label: 'Included In', | 
 |   title: 'Open Included In dialog', | 
 |   __key: 'includedIn', | 
 |   __primary: false, | 
 |   __type: ActionType.CHANGE, | 
 | }; | 
 |  | 
 | const REBASE_EDIT: UIActionInfo = { | 
 |   enabled: true, | 
 |   label: 'Rebase edit', | 
 |   title: 'Rebase change edit', | 
 |   __key: 'rebaseEdit', | 
 |   __primary: false, | 
 |   __type: ActionType.CHANGE, | 
 |   method: HttpMethod.POST, | 
 | }; | 
 |  | 
 | const PUBLISH_EDIT: UIActionInfo = { | 
 |   enabled: true, | 
 |   label: 'Publish edit', | 
 |   title: 'Publish change edit', | 
 |   __key: 'publishEdit', | 
 |   __primary: false, | 
 |   __type: ActionType.CHANGE, | 
 |   method: HttpMethod.POST, | 
 | }; | 
 |  | 
 | const DELETE_EDIT: UIActionInfo = { | 
 |   enabled: true, | 
 |   label: 'Delete edit', | 
 |   title: 'Delete change edit', | 
 |   __key: 'deleteEdit', | 
 |   __primary: false, | 
 |   __type: ActionType.CHANGE, | 
 |   method: HttpMethod.DELETE, | 
 | }; | 
 |  | 
 | const EDIT: UIActionInfo = { | 
 |   enabled: true, | 
 |   label: 'Edit', | 
 |   title: 'Edit this change', | 
 |   __key: 'edit', | 
 |   __primary: false, | 
 |   __type: ActionType.CHANGE, | 
 | }; | 
 |  | 
 | const STOP_EDIT: UIActionInfo = { | 
 |   enabled: true, | 
 |   label: 'Stop editing', | 
 |   title: 'Stop editing this change', | 
 |   __key: 'stopEdit', | 
 |   __primary: false, | 
 |   __type: ActionType.CHANGE, | 
 | }; | 
 |  | 
 | // Set of keys that have icons. | 
 | const ACTIONS_WITH_ICONS = new Map< | 
 |   string, | 
 |   Pick<UIActionInfo, 'filled' | 'icon'> | 
 | >([ | 
 |   [ChangeActions.ABANDON, {icon: 'block'}], | 
 |   [ChangeActions.DELETE_EDIT, {icon: 'delete', filled: true}], | 
 |   [ChangeActions.EDIT, {icon: 'edit', filled: true}], | 
 |   [ChangeActions.PUBLISH_EDIT, {icon: 'publish', filled: true}], | 
 |   [ChangeActions.READY, {icon: 'visibility', filled: true}], | 
 |   [ChangeActions.REBASE_EDIT, {icon: 'rebase_edit'}], | 
 |   [RevisionActions.REBASE, {icon: 'rebase'}], | 
 |   [ChangeActions.RESTORE, {icon: 'history'}], | 
 |   [ChangeActions.REVERT, {icon: 'undo'}], | 
 |   [ChangeActions.STOP_EDIT, {icon: 'stop', filled: true}], | 
 |   [QUICK_APPROVE_ACTION.key, {icon: 'check'}], | 
 |   [RevisionActions.SUBMIT, {icon: 'done_all'}], | 
 | ]); | 
 |  | 
 | const EDIT_ACTIONS: Set<string> = new Set([ | 
 |   ChangeActions.DELETE_EDIT, | 
 |   ChangeActions.EDIT, | 
 |   ChangeActions.PUBLISH_EDIT, | 
 |   ChangeActions.REBASE_EDIT, | 
 |   ChangeActions.STOP_EDIT, | 
 | ]); | 
 |  | 
 | const AWAIT_CHANGE_ATTEMPTS = 5; | 
 | const AWAIT_CHANGE_TIMEOUT_MS = 1000; | 
 |  | 
 | const SKIP_ACTION_KEYS: string[] = [ | 
 |   // REVIEWED/UNREVIEWED is made obsolete by AttentionSet. Once the | 
 |   // backend stops supporting (UN)REVIEWED, we can remove these. | 
 |   ChangeActions.REVIEWED, | 
 |   ChangeActions.UNREVIEWED, | 
 |   // REVERT_SUBMISSION is folded into the dialog for REVERT. | 
 |   ChangeActions.REVERT_SUBMISSION, | 
 | ]; | 
 |  | 
 | export function assertUIActionInfo(action?: ActionInfo): UIActionInfo { | 
 |   // TODO(TS): Remove this function. The gr-change-actions adds properties | 
 |   // to existing ActionInfo objects instead of creating a new objects. This | 
 |   // function checks, that 'action' has all property required by UIActionInfo. | 
 |   // In the future, we should avoid updates of an existing ActionInfos and | 
 |   // instead create a new object to make code cleaner. However, at the current | 
 |   // state this is unsafe, because other code can expect these properties to be | 
 |   // set in ActionInfo. | 
 |   if (!action) { | 
 |     throw new Error('action is undefined'); | 
 |   } | 
 |   const result = action as UIActionInfo; | 
 |   if (result.__key === undefined || result.__type === undefined) { | 
 |     throw new Error('action is not an UIActionInfo'); | 
 |   } | 
 |   return result; | 
 | } | 
 |  | 
 | interface MenuAction { | 
 |   name: string; | 
 |   id: string; | 
 |   action: UIActionInfo; | 
 |   tooltip?: string; | 
 | } | 
 |  | 
 | interface OverflowAction { | 
 |   type: ActionType; | 
 |   key: string; | 
 | } | 
 |  | 
 | interface ActionPriorityOverride { | 
 |   type: ActionType.CHANGE | ActionType.REVISION; | 
 |   key: string; | 
 |   priority: ActionPriority; | 
 | } | 
 |  | 
 | @customElement('gr-change-actions') | 
 | export class GrChangeActions | 
 |   extends LitElement | 
 |   implements GrChangeActionsElement | 
 | { | 
 |   /** | 
 |    * Fired when the change should be reloaded. | 
 |    * | 
 |    * @event reload | 
 |    */ | 
 |  | 
 |   /** | 
 |    * Fired when an action is tapped. | 
 |    * | 
 |    * @event custom-tap - naming pattern: <action key>-tap | 
 |    */ | 
 |  | 
 |   @query('#mainContent') mainContent?: Element; | 
 |  | 
 |   @query('#actionsModal') actionsModal?: HTMLDialogElement; | 
 |  | 
 |   @query('#confirmRebase') confirmRebase?: GrConfirmRebaseDialog; | 
 |  | 
 |   @query('#confirmCherrypick') confirmCherrypick?: GrConfirmCherrypickDialog; | 
 |  | 
 |   @query('#confirmCherrypickConflict') | 
 |   confirmCherrypickConflict?: GrConfirmCherrypickConflictDialog; | 
 |  | 
 |   @query('#confirmMove') confirmMove?: GrConfirmMoveDialog; | 
 |  | 
 |   @query('#confirmRevertDialog') confirmRevertDialog?: GrConfirmRevertDialog; | 
 |  | 
 |   @query('#confirmAbandonDialog') confirmAbandonDialog?: GrConfirmAbandonDialog; | 
 |  | 
 |   @query('#confirmSubmitDialog') confirmSubmitDialog?: GrConfirmSubmitDialog; | 
 |  | 
 |   @query('#createFollowUpDialog') createFollowUpDialog?: GrDialog; | 
 |  | 
 |   @query('#createFollowUpChange') createFollowUpChange?: GrCreateChangeDialog; | 
 |  | 
 |   @query('#confirmDeleteDialog') confirmDeleteDialog?: GrDialog; | 
 |  | 
 |   @query('#confirmDeleteEditDialog') confirmDeleteEditDialog?: GrDialog; | 
 |  | 
 |   @query('#confirmPublishEditDialog') confirmPublishEditDialog?: GrDialog; | 
 |  | 
 |   @query('#moreActions') moreActions?: GrDropdown; | 
 |  | 
 |   @query('#secondaryActions') secondaryActions?: HTMLElement; | 
 |  | 
 |   @state() change?: ParsedChangeInfo; | 
 |  | 
 |   @state() actions: ActionNameToActionInfoMap = {}; | 
 |  | 
 |   @state() primaryActionKeys: PrimaryActionKey[] = [ | 
 |     ChangeActions.READY, | 
 |     RevisionActions.SUBMIT, | 
 |   ]; | 
 |  | 
 |   @state() _hideQuickApproveAction = false; | 
 |  | 
 |   @state() account?: AccountInfo; | 
 |  | 
 |   @state() changeNum?: NumericChangeId; | 
 |  | 
 |   @state() changeStatus?: ChangeStatus; | 
 |  | 
 |   @state() mergeable?: boolean; | 
 |  | 
 |   @state() commitNum?: CommitId; | 
 |  | 
 |   @state() latestPatchNum?: PatchSetNumber; | 
 |  | 
 |   @state() commitMessage = ''; | 
 |  | 
 |   // The unfiltered result of calling `restApiService.getChangeRevisionActions()`. | 
 |   // The DOWNLOAD action is also added to it in `actionsChanged()`. | 
 |   @state() revisionActions?: ActionNameToActionInfoMap; | 
 |  | 
 |   @state() privateByDefault?: InheritedBooleanInfo; | 
 |  | 
 |   @state() actionLoadingMessage = ''; | 
 |  | 
 |   @state() inProgressActionKeys = new Set<string>(); | 
 |  | 
 |   @state() allActionValues: UIActionInfo[] = []; | 
 |  | 
 |   @state() topLevelActions?: UIActionInfo[]; | 
 |  | 
 |   @state() topLevelPrimaryActions?: UIActionInfo[]; | 
 |  | 
 |   @state() topLevelSecondaryActions?: UIActionInfo[]; | 
 |  | 
 |   @state() menuActions?: MenuAction[]; | 
 |  | 
 |   @state() overflowActions: OverflowAction[] = [ | 
 |     { | 
 |       type: ActionType.CHANGE, | 
 |       key: ChangeActions.WIP, | 
 |     }, | 
 |     { | 
 |       type: ActionType.CHANGE, | 
 |       key: ChangeActions.DELETE, | 
 |     }, | 
 |     { | 
 |       type: ActionType.REVISION, | 
 |       key: RevisionActions.CHERRYPICK, | 
 |     }, | 
 |     { | 
 |       type: ActionType.CHANGE, | 
 |       key: ChangeActions.MOVE, | 
 |     }, | 
 |     { | 
 |       type: ActionType.REVISION, | 
 |       key: RevisionActions.DOWNLOAD, | 
 |     }, | 
 |     { | 
 |       type: ActionType.CHANGE, | 
 |       key: ChangeActions.REVIEWED, | 
 |     }, | 
 |     { | 
 |       type: ActionType.CHANGE, | 
 |       key: ChangeActions.UNREVIEWED, | 
 |     }, | 
 |     { | 
 |       type: ActionType.CHANGE, | 
 |       key: ChangeActions.PRIVATE, | 
 |     }, | 
 |     { | 
 |       type: ActionType.CHANGE, | 
 |       key: ChangeActions.PRIVATE_DELETE, | 
 |     }, | 
 |     { | 
 |       type: ActionType.CHANGE, | 
 |       key: ChangeActions.FOLLOW_UP, | 
 |     }, | 
 |     { | 
 |       type: ActionType.CHANGE, | 
 |       key: ChangeActions.INCLUDED_IN, | 
 |     }, | 
 |   ]; | 
 |  | 
 |   @state() actionPriorityOverrides: ActionPriorityOverride[] = []; | 
 |  | 
 |   @state() additionalActions: UIActionInfo[] = []; | 
 |  | 
 |   @state() hiddenActions: string[] = []; | 
 |  | 
 |   @state() disabledMenuActions: string[] = []; | 
 |  | 
 |   @state() editPatchsetLoaded = false; | 
 |  | 
 |   @state() editMode = false; | 
 |  | 
 |   @state() editBasedOnCurrentPatchSet = true; | 
 |  | 
 |   @state() loggedIn = false; | 
 |  | 
 |   @state() pluginsLoaded = false; | 
 |  | 
 |   @state() threadsWithUnappliedSuggestions?: CommentThread[]; | 
 |  | 
 |   private readonly restApiService = getAppContext().restApiService; | 
 |  | 
 |   private readonly reporting = getAppContext().reportingService; | 
 |  | 
 |   private readonly getPluginLoader = resolve(this, pluginLoaderToken); | 
 |  | 
 |   private readonly getUserModel = resolve(this, userModelToken); | 
 |  | 
 |   private readonly getConfigModel = resolve(this, configModelToken); | 
 |  | 
 |   private readonly getChangeModel = resolve(this, changeModelToken); | 
 |  | 
 |   private readonly getStorage = resolve(this, storageServiceToken); | 
 |  | 
 |   private readonly getNavigation = resolve(this, navigationToken); | 
 |  | 
 |   private readonly getCommentsModel = resolve(this, commentsModelToken); | 
 |  | 
 |   constructor() { | 
 |     super(); | 
 |     subscribe( | 
 |       this, | 
 |       () => this.getChangeModel().latestPatchNum$, | 
 |       x => (this.latestPatchNum = x) | 
 |     ); | 
 |     subscribe( | 
 |       this, | 
 |       () => this.getChangeModel().patchsets$, | 
 |       x => (this.editBasedOnCurrentPatchSet = hasEditBasedOnCurrentPatchSet(x)) | 
 |     ); | 
 |     subscribe( | 
 |       this, | 
 |       () => this.getChangeModel().patchNum$, | 
 |       x => (this.editPatchsetLoaded = x === 'edit') | 
 |     ); | 
 |     subscribe( | 
 |       this, | 
 |       () => this.getChangeModel().changeNum$, | 
 |       x => (this.changeNum = x) | 
 |     ); | 
 |     subscribe( | 
 |       this, | 
 |       () => this.getChangeModel().change$, | 
 |       x => (this.change = x) | 
 |     ); | 
 |     subscribe( | 
 |       this, | 
 |       () => this.getChangeModel().status$, | 
 |       x => (this.changeStatus = x) | 
 |     ); | 
 |     subscribe( | 
 |       this, | 
 |       () => this.getChangeModel().mergeable$, | 
 |       x => (this.mergeable = x) | 
 |     ); | 
 |     subscribe( | 
 |       this, | 
 |       () => this.getChangeModel().editMode$, | 
 |       x => (this.editMode = x) | 
 |     ); | 
 |     subscribe( | 
 |       this, | 
 |       () => this.getChangeModel().revision$, | 
 |       rev => (this.commitNum = rev?.commit?.commit) | 
 |     ); | 
 |     subscribe( | 
 |       this, | 
 |       () => this.getChangeModel().latestRevision$, | 
 |       rev => (this.commitMessage = rev?.commit?.message ?? '') | 
 |     ); | 
 |     subscribe( | 
 |       this, | 
 |       () => this.getUserModel().account$, | 
 |       x => (this.account = x) | 
 |     ); | 
 |     subscribe( | 
 |       this, | 
 |       () => this.getUserModel().loggedIn$, | 
 |       x => (this.loggedIn = x) | 
 |     ); | 
 |     subscribe( | 
 |       this, | 
 |       () => this.getPluginLoader().pluginsModel.pluginsLoaded$, | 
 |       x => (this.pluginsLoaded = x) | 
 |     ); | 
 |     subscribe( | 
 |       this, | 
 |       () => this.getConfigModel().repoConfig$, | 
 |       config => (this.privateByDefault = config?.private_by_default) | 
 |     ); | 
 |     subscribe( | 
 |       this, | 
 |       () => this.getCommentsModel().threadsWithUnappliedSuggestions$, | 
 |       x => (this.threadsWithUnappliedSuggestions = x) | 
 |     ); | 
 |   } | 
 |  | 
 |   override connectedCallback() { | 
 |     super.connectedCallback(); | 
 |     this.getPluginLoader().jsApiService.addElement( | 
 |       TargetElement.CHANGE_ACTIONS, | 
 |       this | 
 |     ); | 
 |   } | 
 |  | 
 |   static override get styles() { | 
 |     return [ | 
 |       sharedStyles, | 
 |       modalStyles, | 
 |       css` | 
 |         :host { | 
 |           display: flex; | 
 |           font-family: var(--font-family); | 
 |         } | 
 |         #actionLoadingMessage, | 
 |         #mainContent, | 
 |         section { | 
 |           display: flex; | 
 |         } | 
 |         #actionLoadingMessage, | 
 |         gr-button, | 
 |         gr-dropdown { | 
 |           /* px because don't have the same font size */ | 
 |           margin-left: 8px; | 
 |         } | 
 |         gr-button { | 
 |           display: block; | 
 |         } | 
 |         #actionLoadingMessage { | 
 |           align-items: center; | 
 |           color: var(--deemphasized-text-color); | 
 |         } | 
 |         #confirmSubmitDialog .changeSubject { | 
 |           margin: var(--spacing-l); | 
 |           text-align: center; | 
 |         } | 
 |         gr-icon { | 
 |           color: inherit; | 
 |           margin-right: var(--spacing-xs); | 
 |         } | 
 |         #moreActions gr-icon { | 
 |           margin: 0; | 
 |         } | 
 |         #moreMessage, | 
 |         .hidden { | 
 |           display: none; | 
 |         } | 
 |         .info { | 
 |           background-color: var(--info-background); | 
 |           padding: var(--spacing-l) var(--spacing-xl); | 
 |           margin-bottom: var(--spacing-l); | 
 |         } | 
 |         .info gr-icon { | 
 |           color: var(--selected-foreground); | 
 |           margin-right: var(--spacing-xl); | 
 |         } | 
 |         @media screen and (max-width: 50em) { | 
 |           #mainContent { | 
 |             flex-wrap: wrap; | 
 |           } | 
 |           gr-button { | 
 |             --gr-button-padding: var(--spacing-m); | 
 |             white-space: nowrap; | 
 |           } | 
 |           gr-button, | 
 |           gr-dropdown { | 
 |             margin: 0; | 
 |           } | 
 |           #actionLoadingMessage { | 
 |             margin: var(--spacing-m); | 
 |             text-align: center; | 
 |           } | 
 |           #moreMessage { | 
 |             display: inline; | 
 |           } | 
 |         } | 
 |       `, | 
 |     ]; | 
 |   } | 
 |  | 
 |   override render() { | 
 |     if (!this.change) return nothing; | 
 |     return html` | 
 |       <div id="mainContent"> | 
 |         <span id="actionLoadingMessage" ?hidden=${!this.actionLoadingMessage}> | 
 |           ${this.actionLoadingMessage} | 
 |         </span> | 
 |         <section | 
 |           id="primaryActions" | 
 |           ?hidden=${this.isLoading() || | 
 |           !this.topLevelActions || | 
 |           !this.topLevelActions.length} | 
 |         > | 
 |           ${this.topLevelPrimaryActions?.map(action => | 
 |             this.renderUIAction(action) | 
 |           )} | 
 |         </section> | 
 |         <section | 
 |           id="secondaryActions" | 
 |           ?hidden=${this.isLoading() || | 
 |           !this.topLevelActions || | 
 |           !this.topLevelActions.length} | 
 |         > | 
 |           ${this.topLevelSecondaryActions?.map(action => | 
 |             this.renderUIAction(action) | 
 |           )} | 
 |         </section> | 
 |         <gr-button ?hidden=${!this.isLoading()}>Loading actions...</gr-button> | 
 |         <gr-dropdown | 
 |           id="moreActions" | 
 |           link | 
 |           @tap-item=${this.handleOverflowItemTap} | 
 |           ?hidden=${this.isLoading() || | 
 |           !this.menuActions || | 
 |           !this.menuActions.length} | 
 |           .disabledIds=${this.disabledMenuActions} | 
 |           .items=${this.menuActions} | 
 |         > | 
 |           <gr-icon icon="more_vert" aria-labelledby="moreMessage"></gr-icon> | 
 |           <span id="moreMessage">More</span> | 
 |         </gr-dropdown> | 
 |       </div> | 
 |       <dialog id="actionsModal" tabindex="-1"> | 
 |         <gr-confirm-rebase-dialog | 
 |           id="confirmRebase" | 
 |           class="confirmDialog" | 
 |           @confirm-rebase=${this.handleRebaseConfirm} | 
 |           @cancel=${this.handleConfirmDialogCancel} | 
 |           .disableActions=${this.inProgressActionKeys.has( | 
 |             RevisionActions.REBASE | 
 |           )} | 
 |           .branch=${this.change?.branch} | 
 |           .rebaseOnCurrent=${!!this.revisionActions?.rebase?.enabled} | 
 |         ></gr-confirm-rebase-dialog> | 
 |         <gr-confirm-cherrypick-dialog | 
 |           id="confirmCherrypick" | 
 |           class="confirmDialog" | 
 |           .changeStatus=${this.changeStatus} | 
 |           .commitMessage=${this.commitMessage} | 
 |           .commitNum=${this.commitNum} | 
 |           @confirm=${this.handleCherrypickConfirm} | 
 |           @cancel=${this.handleConfirmDialogCancel} | 
 |           .project=${this.change?.project} | 
 |         ></gr-confirm-cherrypick-dialog> | 
 |         <gr-confirm-cherrypick-conflict-dialog | 
 |           id="confirmCherrypickConflict" | 
 |           class="confirmDialog" | 
 |           @confirm=${this.handleCherrypickConflictConfirm} | 
 |           @cancel=${this.handleConfirmDialogCancel} | 
 |         ></gr-confirm-cherrypick-conflict-dialog> | 
 |         <gr-confirm-move-dialog | 
 |           id="confirmMove" | 
 |           class="confirmDialog" | 
 |           @confirm=${this.handleMoveConfirm} | 
 |           @cancel=${this.handleConfirmDialogCancel} | 
 |           .project=${this.change?.project} | 
 |         ></gr-confirm-move-dialog> | 
 |         <gr-confirm-revert-dialog | 
 |           id="confirmRevertDialog" | 
 |           class="confirmDialog" | 
 |           @confirm-revert=${this.handleRevertDialogConfirm} | 
 |           @cancel=${this.handleConfirmDialogCancel} | 
 |         ></gr-confirm-revert-dialog> | 
 |         <gr-confirm-abandon-dialog | 
 |           id="confirmAbandonDialog" | 
 |           class="confirmDialog" | 
 |           @confirm=${this.handleAbandonDialogConfirm} | 
 |           @cancel=${this.handleConfirmDialogCancel} | 
 |         ></gr-confirm-abandon-dialog> | 
 |         <gr-confirm-submit-dialog | 
 |           id="confirmSubmitDialog" | 
 |           class="confirmDialog" | 
 |           .action=${this.revisionActions?.submit} | 
 |           @cancel=${this.handleConfirmDialogCancel} | 
 |           @confirm=${this.handleSubmitConfirm} | 
 |         ></gr-confirm-submit-dialog> | 
 |         <gr-dialog | 
 |           id="createFollowUpDialog" | 
 |           class="confirmDialog" | 
 |           confirm-label="Create" | 
 |           @confirm=${this.handleCreateFollowUpChange} | 
 |           @cancel=${this.handleCloseCreateFollowUpChange} | 
 |         > | 
 |           <div class="header" slot="header">Create Follow-Up Change</div> | 
 |           <div class="main" slot="main"> | 
 |             <gr-create-change-dialog | 
 |               id="createFollowUpChange" | 
 |               .branch=${this.change?.branch} | 
 |               .baseChange=${this.change?.change_id} | 
 |               .repoName=${this.change?.project} | 
 |               .privateByDefault=${this.privateByDefault} | 
 |             ></gr-create-change-dialog> | 
 |           </div> | 
 |         </gr-dialog> | 
 |         <gr-dialog | 
 |           id="confirmDeleteDialog" | 
 |           class="confirmDialog" | 
 |           confirm-label="Delete" | 
 |           confirm-on-enter="" | 
 |           @cancel=${this.handleConfirmDialogCancel} | 
 |           @confirm=${this.handleDeleteConfirm} | 
 |         > | 
 |           <div class="header" slot="header">Delete Change</div> | 
 |           <div class="main" slot="main"> | 
 |             Do you really want to delete the change? | 
 |           </div> | 
 |         </gr-dialog> | 
 |         <gr-dialog | 
 |           id="confirmDeleteEditDialog" | 
 |           class="confirmDialog" | 
 |           confirm-label="Delete" | 
 |           confirm-on-enter="" | 
 |           @cancel=${this.handleConfirmDialogCancel} | 
 |           @confirm=${this.handleDeleteEditConfirm} | 
 |         > | 
 |           <div class="header" slot="header">Delete Change Edit</div> | 
 |           <div class="main" slot="main"> | 
 |             Do you really want to delete the edit? | 
 |           </div> | 
 |         </gr-dialog> | 
 |         <gr-dialog | 
 |           id="confirmPublishEditDialog" | 
 |           class="confirmDialog" | 
 |           confirm-label="Publish" | 
 |           confirm-on-enter="" | 
 |           @cancel=${this.handleConfirmDialogCancel} | 
 |           @confirm=${this.handlePublishEditConfirm} | 
 |         > | 
 |           <div class="header" slot="header">Publish Change Edit</div> | 
 |           <div class="main" slot="main"> | 
 |             ${when( | 
 |               this.numberOfThreadsWithUnappliedSuggestions() > 0, | 
 |               () => html`<p class="info"> | 
 |                 <gr-icon id="icon" icon="info" small></gr-icon> | 
 |                 Heads Up! ${this.numberOfThreadsWithUnappliedSuggestions()} | 
 |                 comments have suggestions you can apply before publishing | 
 |               </p>` | 
 |             )} | 
 |             Do you really want to publish the edit? | 
 |           </div> | 
 |         </gr-dialog> | 
 |       </dialog> | 
 |     `; | 
 |   } | 
 |  | 
 |   private renderUIAction(action: UIActionInfo) { | 
 |     return html` | 
 |       <gr-tooltip-content | 
 |         title=${ifDefined(action.title)} | 
 |         .hasTooltip=${!!action.title} | 
 |         ?position-below=${true} | 
 |       > | 
 |         <gr-button | 
 |           link | 
 |           class=${action.__key} | 
 |           data-action-key=${action.__key} | 
 |           data-label=${action.label} | 
 |           ?disabled=${this.calculateDisabled(action)} | 
 |           @click=${(e: MouseEvent) => | 
 |             this.handleActionTap(e, action.__key, action.__type)} | 
 |         > | 
 |           ${this.renderUIActionIcon(action)} ${action.label} | 
 |         </gr-button> | 
 |       </gr-tooltip-content> | 
 |     `; | 
 |   } | 
 |  | 
 |   private renderUIActionIcon(action: UIActionInfo) { | 
 |     if (!action.icon) return nothing; | 
 |     return html` | 
 |       <gr-icon icon=${action.icon} ?filled=${!!action.filled}></gr-icon> | 
 |     `; | 
 |   } | 
 |  | 
 |   override willUpdate(changedProperties: PropertyValues) { | 
 |     if (changedProperties.has('change')) { | 
 |       this.reload(); | 
 |       this.actions = this.change?.actions ?? {}; | 
 |     } | 
 |  | 
 |     this.editStatusChanged(); | 
 |  | 
 |     if ( | 
 |       changedProperties.has('actions') || | 
 |       changedProperties.has('revisionActions') || | 
 |       changedProperties.has('additionalActions') | 
 |     ) { | 
 |       this.actionsChanged(); | 
 |     } | 
 |  | 
 |     this.allActionValues = this.computeAllActions(); | 
 |     this.topLevelActions = this.allActionValues.filter(a => { | 
 |       if (this.hiddenActions.includes(a.__key)) return false; | 
 |       if (this.editMode) return EDIT_ACTIONS.has(a.__key); | 
 |       return !this.isOverflowAction(a.__type, a.__key); | 
 |     }); | 
 |     this.topLevelPrimaryActions = this.topLevelActions.filter( | 
 |       action => action.__primary | 
 |     ); | 
 |     this.topLevelSecondaryActions = this.topLevelActions.filter( | 
 |       action => !action.__primary | 
 |     ); | 
 |     this.menuActions = this.computeMenuActions(); | 
 |   } | 
 |  | 
 |   reload() { | 
 |     if (!this.changeNum || !this.latestPatchNum || !this.change) { | 
 |       return Promise.resolve(); | 
 |     } | 
 |     const change = this.change; | 
 |     this.revisionActions = undefined; | 
 |     return this.restApiService | 
 |       .getChangeRevisionActions(this.changeNum, this.latestPatchNum) | 
 |       .then(revisionActions => { | 
 |         this.revisionActions = revisionActions ?? {}; | 
 |         this.sendShowRevisionActions({ | 
 |           change: change as ChangeInfo, | 
 |           revisionActions: this.revisionActions, | 
 |         }); | 
 |       }) | 
 |       .catch(err => { | 
 |         fireAlert(this, ERR_REVISION_ACTIONS); | 
 |         throw err; | 
 |       }); | 
 |   } | 
 |  | 
 |   private isLoading() { | 
 |     return ( | 
 |       !this.pluginsLoaded || | 
 |       !this.change || | 
 |       this.mergeable === undefined || | 
 |       this.revisionActions === undefined | 
 |     ); | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   sendShowRevisionActions(detail: ShowRevisionActionsDetail) { | 
 |     this.getPluginLoader().jsApiService.handleShowRevisionActions(detail); | 
 |   } | 
 |  | 
 |   addActionButton(type: ActionType, label: string) { | 
 |     if (type !== ActionType.CHANGE && type !== ActionType.REVISION) { | 
 |       throw Error(`Invalid action type: ${type}`); | 
 |     } | 
 |     const action: UIActionInfo = { | 
 |       enabled: true, | 
 |       label, | 
 |       __type: type, | 
 |       __key: ADDITIONAL_ACTION_KEY_PREFIX + uuid(), | 
 |     }; | 
 |     this.additionalActions.push(action); | 
 |     this.requestUpdate('additionalActions'); | 
 |     return action.__key; | 
 |   } | 
 |  | 
 |   removeActionButton(key: string) { | 
 |     const idx = this.indexOfActionButtonWithKey(key); | 
 |     if (idx === -1) { | 
 |       return; | 
 |     } | 
 |     this.additionalActions.splice(idx, 1); | 
 |     this.requestUpdate('additionalActions'); | 
 |   } | 
 |  | 
 |   setActionButtonProp<T extends keyof UIActionInfo>( | 
 |     key: string, | 
 |     prop: T, | 
 |     value: UIActionInfo[T] | 
 |   ) { | 
 |     this.additionalActions[this.indexOfActionButtonWithKey(key)][prop] = value; | 
 |     this.requestUpdate('additionalActions'); | 
 |   } | 
 |  | 
 |   // TODO: Rename to toggleOverflow(). | 
 |   setActionOverflow(type: ActionType, key: string, overflow: boolean) { | 
 |     if (type !== ActionType.CHANGE && type !== ActionType.REVISION) { | 
 |       throw Error(`Invalid action type given: ${type}`); | 
 |     } | 
 |     const isCurrentlyOverflow = this.isOverflowAction(type, key); | 
 |     if (overflow === isCurrentlyOverflow) { | 
 |       return; | 
 |     } | 
 |  | 
 |     // remove from overflowActions | 
 |     if (!overflow) { | 
 |       this.overflowActions = this.overflowActions.filter( | 
 |         action => action.type !== type || action.key !== key | 
 |       ); | 
 |     } | 
 |     // add to overflowActions | 
 |     if (overflow) { | 
 |       this.overflowActions = [...this.overflowActions, {type, key}]; | 
 |     } | 
 |   } | 
 |  | 
 |   setActionPriority( | 
 |     type: ActionType.CHANGE | ActionType.REVISION, | 
 |     key: string, | 
 |     priority: ActionPriority | 
 |   ) { | 
 |     if (type !== ActionType.CHANGE && type !== ActionType.REVISION) { | 
 |       throw Error(`Invalid action type given: ${type}`); | 
 |     } | 
 |     const index = this.actionPriorityOverrides.findIndex( | 
 |       action => action.type === type && action.key === key | 
 |     ); | 
 |     const action: ActionPriorityOverride = { | 
 |       type, | 
 |       key, | 
 |       priority, | 
 |     }; | 
 |     if (index !== -1) { | 
 |       this.actionPriorityOverrides[index] = action; | 
 |       this.requestUpdate('actionPriorityOverrides'); | 
 |     } else { | 
 |       this.actionPriorityOverrides.push(action); | 
 |       this.requestUpdate('actionPriorityOverrides'); | 
 |     } | 
 |   } | 
 |  | 
 |   setActionHidden( | 
 |     type: ActionType.CHANGE | ActionType.REVISION, | 
 |     key: string, | 
 |     hidden: boolean | 
 |   ) { | 
 |     if (type !== ActionType.CHANGE && type !== ActionType.REVISION) { | 
 |       throw Error(`Invalid action type given: ${type}`); | 
 |     } | 
 |  | 
 |     const idx = this.hiddenActions.indexOf(key); | 
 |     if (hidden && idx === -1) { | 
 |       this.hiddenActions.push(key); | 
 |       this.requestUpdate('hiddenActions'); | 
 |     } else if (!hidden && idx !== -1) { | 
 |       this.hiddenActions.splice(idx, 1); | 
 |       this.requestUpdate('hiddenActions'); | 
 |     } | 
 |   } | 
 |  | 
 |   getActionDetails(actionName: string) { | 
 |     if (this.revisionActions?.[actionName]) { | 
 |       return this.revisionActions[actionName]; | 
 |     } else if (this.actions?.[actionName]) { | 
 |       return this.actions[actionName]; | 
 |     } else { | 
 |       return undefined; | 
 |     } | 
 |   } | 
 |  | 
 |   private indexOfActionButtonWithKey(key: string) { | 
 |     return this.additionalActions.findIndex(action => action.__key === key); | 
 |   } | 
 |  | 
 |   private actionsChanged() { | 
 |     this.actionLoadingMessage = ''; | 
 |     this.disabledMenuActions = []; | 
 |  | 
 |     if (this.revisionActions && !this.revisionActions.download) { | 
 |       this.revisionActions = { | 
 |         ...this.revisionActions, | 
 |         download: DOWNLOAD_ACTION, | 
 |       }; | 
 |       fire(this, 'revision-actions-changed', { | 
 |         value: this.revisionActions, | 
 |       }); | 
 |     } | 
 |     if ( | 
 |       !this.actions.includedIn && | 
 |       this.change?.status === ChangeStatus.MERGED | 
 |     ) { | 
 |       this.actions = {...this.actions, includedIn: INCLUDED_IN_ACTION}; | 
 |     } | 
 |   } | 
 |  | 
 |   private editStatusChanged() { | 
 |     if (!this.change || !this.loggedIn) return; | 
 |     if (this.editPatchsetLoaded) { | 
 |       // Only show actions that mutate an edit if an actual edit patch set | 
 |       // is loaded. | 
 |       if (changeIsOpen(this.change)) { | 
 |         if (this.editBasedOnCurrentPatchSet) { | 
 |           if (!this.actions.publishEdit) { | 
 |             this.actions = {...this.actions, publishEdit: PUBLISH_EDIT}; | 
 |           } | 
 |           delete this.actions.rebaseEdit; | 
 |         } else { | 
 |           if (!this.actions.rebaseEdit) { | 
 |             this.actions = {...this.actions, rebaseEdit: REBASE_EDIT}; | 
 |           } | 
 |           delete this.actions.publishEdit; | 
 |         } | 
 |       } | 
 |       if (!this.actions.deleteEdit) { | 
 |         this.actions = {...this.actions, deleteEdit: DELETE_EDIT}; | 
 |       } | 
 |     } else { | 
 |       delete this.actions.rebaseEdit; | 
 |       delete this.actions.publishEdit; | 
 |       delete this.actions.deleteEdit; | 
 |     } | 
 |  | 
 |     if (changeIsOpen(this.change)) { | 
 |       // Only show edit button if there is no edit patchset loaded and the | 
 |       // file list is not in edit mode. | 
 |       if (this.editPatchsetLoaded || this.editMode) { | 
 |         delete this.actions.edit; | 
 |       } else { | 
 |         if (!this.actions.edit) { | 
 |           this.actions = {...this.actions, edit: EDIT}; | 
 |         } | 
 |       } | 
 |       // Only show STOP_EDIT if edit mode is enabled, but no edit patch set | 
 |       // is loaded. | 
 |       if (this.editMode && !this.editPatchsetLoaded) { | 
 |         if (!this.actions.stopEdit) { | 
 |           this.actions = {...this.actions, stopEdit: STOP_EDIT}; | 
 |           fireAlert(this, 'Change is in edit mode'); | 
 |         } | 
 |       } else { | 
 |         delete this.actions.stopEdit; | 
 |       } | 
 |     } else { | 
 |       // Remove edit button. | 
 |       delete this.actions.edit; | 
 |     } | 
 |   } | 
 |  | 
 |   private getValuesFor<T>(obj: {[key: string]: T}): T[] { | 
 |     return Object.keys(obj).map(key => obj[key]); | 
 |   } | 
 |  | 
 |   private getLabelStatus(label: LabelInfo): LabelStatus { | 
 |     if (isQuickLabelInfo(label)) { | 
 |       if (label.approved) { | 
 |         return LabelStatus.OK; | 
 |       } else if (label.rejected) { | 
 |         return LabelStatus.REJECT; | 
 |       } | 
 |     } | 
 |     if (label.optional) { | 
 |       return LabelStatus.OPTIONAL; | 
 |     } else { | 
 |       return LabelStatus.NEED; | 
 |     } | 
 |   } | 
 |  | 
 |   /** | 
 |    * Get highest score for last missing permitted label for current change. | 
 |    * Returns null if no labels permitted or more than one label missing. | 
 |    */ | 
 |   private getTopMissingApproval() { | 
 |     if (!this.change || !this.change.labels || !this.change.permitted_labels) { | 
 |       return null; | 
 |     } | 
 |     if (this.change?.status === ChangeStatus.MERGED) { | 
 |       return null; | 
 |     } | 
 |     let result; | 
 |     for (const [label, labelInfo] of Object.entries(this.change.labels)) { | 
 |       if (!(label in this.change.permitted_labels)) { | 
 |         continue; | 
 |       } | 
 |       if (this.change.permitted_labels[label].length === 0) { | 
 |         continue; | 
 |       } | 
 |       const status = this.getLabelStatus(labelInfo); | 
 |       if (status === LabelStatus.NEED) { | 
 |         if (result) { | 
 |           // More than one label is missing, so check if Code Review can be | 
 |           // given | 
 |           result = null; | 
 |           break; | 
 |         } | 
 |         result = label; | 
 |       } else if ( | 
 |         status === LabelStatus.REJECT || | 
 |         status === LabelStatus.IMPOSSIBLE | 
 |       ) { | 
 |         return null; | 
 |       } | 
 |     } | 
 |     // Allow the user to use quick approve to vote the max score on code review | 
 |     // even if it is already granted by someone else. Does not apply if the | 
 |     // user owns the change or has already granted the max score themselves. | 
 |     const codeReviewLabel = this.change.labels[StandardLabels.CODE_REVIEW]; | 
 |     const codeReviewPermittedValues = | 
 |       this.change.permitted_labels[StandardLabels.CODE_REVIEW]; | 
 |     if ( | 
 |       !result && | 
 |       codeReviewLabel && | 
 |       codeReviewPermittedValues && | 
 |       this.account?._account_id && | 
 |       isDetailedLabelInfo(codeReviewLabel) && | 
 |       !isOwner(this.change, this.account) && | 
 |       getApprovalInfo(codeReviewLabel, this.account)?.value !== | 
 |         getVotingRange(codeReviewLabel)?.max | 
 |     ) { | 
 |       result = StandardLabels.CODE_REVIEW; | 
 |     } | 
 |  | 
 |     if (result) { | 
 |       const labelInfo = this.change.labels[result]; | 
 |       if (!isDetailedLabelInfo(labelInfo)) { | 
 |         return null; | 
 |       } | 
 |       const permittedValues = this.change.permitted_labels[result]; | 
 |       const usersMaxPermittedScore = | 
 |         permittedValues[permittedValues.length - 1]; | 
 |       const maxScoreForLabel = getVotingRange(labelInfo)?.max; | 
 |       if (Number(usersMaxPermittedScore) === maxScoreForLabel) { | 
 |         // Allow quick approve only for maximal score. | 
 |         return { | 
 |           label: result, | 
 |           score: usersMaxPermittedScore, | 
 |         }; | 
 |       } | 
 |     } | 
 |     return null; | 
 |   } | 
 |  | 
 |   hideQuickApproveAction() { | 
 |     if (!this.topLevelSecondaryActions) { | 
 |       throw new Error('topLevelSecondaryActions must be set'); | 
 |     } | 
 |     this.topLevelSecondaryActions = this.topLevelSecondaryActions.filter( | 
 |       sa => !isQuickApproveAction(sa) | 
 |     ); | 
 |     this._hideQuickApproveAction = true; | 
 |   } | 
 |  | 
 |   private getQuickApproveAction(): QuickApproveUIActionInfo | null { | 
 |     if (this._hideQuickApproveAction) { | 
 |       return null; | 
 |     } | 
 |     const approval = this.getTopMissingApproval(); | 
 |     if (!approval) { | 
 |       return null; | 
 |     } | 
 |     const action = {...QUICK_APPROVE_ACTION}; | 
 |     action.label = approval.label + approval.score; | 
 |  | 
 |     const score = Number(approval.score); | 
 |     if (isNaN(score)) { | 
 |       return null; | 
 |     } | 
 |  | 
 |     const review: ReviewInput = { | 
 |       drafts: DraftsAction.PUBLISH_ALL_REVISIONS, | 
 |       labels: { | 
 |         [approval.label]: score, | 
 |       }, | 
 |     }; | 
 |     action.payload = review; | 
 |     return action; | 
 |   } | 
 |  | 
 |   private getActionValues( | 
 |     actionsChange: ActionNameToActionInfoMap | undefined, | 
 |     primariesChange: PrimaryActionKey[], | 
 |     additionalActionsChange: UIActionInfo[], | 
 |     type: ActionType | 
 |   ): UIActionInfo[] { | 
 |     if (!actionsChange || !primariesChange) { | 
 |       return []; | 
 |     } | 
 |  | 
 |     const actions = actionsChange; | 
 |     const primaryActionKeys = primariesChange; | 
 |     const result: UIActionInfo[] = []; | 
 |     const values: Array<ChangeActions | RevisionActions> = | 
 |       type === ActionType.CHANGE | 
 |         ? this.getValuesFor(ChangeActions) | 
 |         : this.getValuesFor(RevisionActions); | 
 |  | 
 |     const pluginActions: UIActionInfo[] = []; | 
 |     Object.keys(actions).forEach(a => { | 
 |       const action: UIActionInfo = actions[a] as UIActionInfo; | 
 |       action.__key = a; | 
 |       action.__type = type; | 
 |       action.__primary = primaryActionKeys.includes(a as PrimaryActionKey); | 
 |       // Plugin actions always contain ~ in the key. | 
 |       if (a.indexOf('~') !== -1) { | 
 |         this.populateActionUrl(action); | 
 |         pluginActions.push(action); | 
 |         // Add server-side provided plugin actions to overflow menu. | 
 |         this.overflowActions.push({ | 
 |           type, | 
 |           key: a, | 
 |         }); | 
 |         this.requestUpdate('overflowActions'); | 
 |         return; | 
 |       } else if (!values.includes(a as PrimaryActionKey)) { | 
 |         return; | 
 |       } | 
 |       action.label = this.getActionLabel(action); | 
 |  | 
 |       // Triggers a re-render by ensuring object inequality. | 
 |       result.push({...action}); | 
 |     }); | 
 |  | 
 |     let additionalActions = additionalActionsChange; | 
 |     additionalActions = additionalActions | 
 |       .filter(a => a.__type === type) | 
 |       .map(a => { | 
 |         a.__primary = primaryActionKeys.includes(a.__key as PrimaryActionKey); | 
 |         // Triggers a re-render by ensuring object inequality. | 
 |         return {...a}; | 
 |       }); | 
 |     return result.concat(additionalActions).concat(pluginActions); | 
 |   } | 
 |  | 
 |   private populateActionUrl(action: UIActionInfo) { | 
 |     const patchNum = | 
 |       action.__type === ActionType.REVISION ? this.latestPatchNum : undefined; | 
 |     if (!this.changeNum) { | 
 |       return; | 
 |     } | 
 |     this.restApiService | 
 |       .getChangeActionURL(this.changeNum, patchNum, '/' + action.__key) | 
 |       .then(url => (action.__url = url)); | 
 |   } | 
 |  | 
 |   /** | 
 |    * Given a change action, return a display label that uses the appropriate | 
 |    * casing or includes explanatory details. | 
 |    */ | 
 |   private getActionLabel(action: UIActionInfo) { | 
 |     if (action.label === 'Delete') { | 
 |       // This label is common within change and revision actions. Make it more | 
 |       // explicit to the user. | 
 |       return 'Delete change'; | 
 |     } else if (action.label === 'WIP') { | 
 |       return 'Mark as work in progress'; | 
 |     } | 
 |     // Otherwise, just map the name to sentence case. | 
 |     return this.toSentenceCase(action.label); | 
 |   } | 
 |  | 
 |   /** | 
 |    * Capitalize the first letter and lowercase all others. | 
 |    * | 
 |    * private but used in test | 
 |    */ | 
 |   toSentenceCase(s: string) { | 
 |     if (!s.length) { | 
 |       return ''; | 
 |     } | 
 |     return s[0].toUpperCase() + s.slice(1).toLowerCase(); | 
 |   } | 
 |  | 
 |   private computeLoadingLabel(action: string) { | 
 |     return ActionLoadingLabels[action] || 'Working...'; | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   canSubmitChange() { | 
 |     if (!this.change) return false; | 
 |     const change = this.change as ChangeInfo; | 
 |     const revision = this.getRevision(change, this.latestPatchNum); | 
 |     return this.getPluginLoader().jsApiService.canSubmitChange( | 
 |       change, | 
 |       revision | 
 |     ); | 
 |   } | 
 |  | 
 |   sendPublishEditEvent() { | 
 |     if (!this.change) return; | 
 |     const change = this.change as ChangeInfo; | 
 |     const revision = this.getRevision(change, this.latestPatchNum); | 
 |     this.getPluginLoader().jsApiService.handlePublishEdit(change, revision); | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   getRevision( | 
 |     change: ChangeInfo, | 
 |     patchNum?: PatchSetNumber | 
 |   ): RevisionInfo | null { | 
 |     for (const rev of Object.values(change.revisions ?? {})) { | 
 |       if (rev._number === patchNum) { | 
 |         return rev; | 
 |       } | 
 |     } | 
 |     return null; | 
 |   } | 
 |  | 
 |   async showRevertDialog() { | 
 |     if (!this.change) return; | 
 |     assertIsDefined(this.confirmRevertDialog, 'confirmRevertDialog'); | 
 |     if ( | 
 |       !(await this.confirmRevertDialog.populate( | 
 |         this.change, | 
 |         this.commitMessage | 
 |       )) | 
 |     ) { | 
 |       // This indicates error in REST response that will show error dialog, no | 
 |       // need to open revert dialog. | 
 |       return; | 
 |     } | 
 |     this.showActionDialog(this.confirmRevertDialog); | 
 |   } | 
 |  | 
 |   showSubmitDialog() { | 
 |     if (!this.canSubmitChange()) { | 
 |       return; | 
 |     } | 
 |     assertIsDefined(this.confirmSubmitDialog, 'confirmSubmitDialog'); | 
 |     this.showActionDialog(this.confirmSubmitDialog); | 
 |   } | 
 |  | 
 |   private async handleActionTap(e: MouseEvent, key: string, type: string) { | 
 |     e.preventDefault(); | 
 |     let el = e.target as Element; | 
 |     while (el.tagName.toLowerCase() !== 'gr-button') { | 
 |       if (!el.parentElement) { | 
 |         return; | 
 |       } | 
 |       el = el.parentElement; | 
 |     } | 
 |  | 
 |     if ( | 
 |       key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) || | 
 |       key.indexOf('~') !== -1 | 
 |     ) { | 
 |       this.dispatchEvent( | 
 |         new CustomEvent(`${key}-tap`, { | 
 |           detail: {node: el}, | 
 |           composed: true, | 
 |           bubbles: true, | 
 |         }) | 
 |       ); | 
 |       return; | 
 |     } | 
 |     await this.handleAction(type as ActionType, key); | 
 |   } | 
 |  | 
 |   private async handleOverflowItemTap(e: CustomEvent<MenuAction>) { | 
 |     e.preventDefault(); | 
 |     const el = e.target as Element; | 
 |     const key = e.detail.action.__key; | 
 |     if ( | 
 |       key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) || | 
 |       key.indexOf('~') !== -1 | 
 |     ) { | 
 |       this.dispatchEvent( | 
 |         new CustomEvent(`${key}-tap`, { | 
 |           detail: {node: el}, | 
 |           composed: true, | 
 |           bubbles: true, | 
 |         }) | 
 |       ); | 
 |       return; | 
 |     } | 
 |     await this.handleAction(e.detail.action.__type, e.detail.action.__key); | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   async handleAction(type: ActionType, key: string) { | 
 |     this.reporting.reportInteraction(`${type}-${key}`); | 
 |     switch (type) { | 
 |       case ActionType.REVISION: | 
 |         this.handleRevisionAction(key); | 
 |         break; | 
 |       case ActionType.CHANGE: | 
 |         await this.handleChangeAction(key); | 
 |         break; | 
 |       default: | 
 |         this.fireAction( | 
 |           this.prependSlash(key), | 
 |           assertUIActionInfo(this.actions[key]), | 
 |           false | 
 |         ); | 
 |     } | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   async handleChangeAction(key: string) { | 
 |     if ( | 
 |       !(await this.getPluginLoader().jsApiService.handleBeforeChangeAction( | 
 |         key, | 
 |         this.change | 
 |       )) | 
 |     ) | 
 |       return; | 
 |     switch (key) { | 
 |       case ChangeActions.REVERT: | 
 |         void this.showRevertDialog(); | 
 |         break; | 
 |       case ChangeActions.ABANDON: | 
 |         assertIsDefined(this.confirmAbandonDialog, 'confirmAbandonDialog'); | 
 |         this.showActionDialog(this.confirmAbandonDialog); | 
 |         break; | 
 |       case QUICK_APPROVE_ACTION.key: { | 
 |         const action = this.allActionValues.find(isQuickApproveAction); | 
 |         if (!action) { | 
 |           return; | 
 |         } | 
 |         this.fireAction(this.prependSlash(key), action, true, action.payload); | 
 |         break; | 
 |       } | 
 |       case ChangeActions.EDIT: | 
 |         this.handleEditTap(); | 
 |         break; | 
 |       case ChangeActions.STOP_EDIT: | 
 |         this.handleStopEditTap(); | 
 |         break; | 
 |       case ChangeActions.DELETE: | 
 |         this.handleDeleteTap(); | 
 |         break; | 
 |       case ChangeActions.DELETE_EDIT: | 
 |         this.handleDeleteEditTap(); | 
 |         break; | 
 |       case ChangeActions.FOLLOW_UP: | 
 |         this.handleFollowUpTap(); | 
 |         break; | 
 |       case ChangeActions.WIP: | 
 |         this.handleWipTap(); | 
 |         break; | 
 |       case ChangeActions.MOVE: | 
 |         this.handleMoveTap(); | 
 |         break; | 
 |       case ChangeActions.PUBLISH_EDIT: | 
 |         this.handlePublishEditTap(); | 
 |         break; | 
 |       case ChangeActions.REBASE_EDIT: | 
 |         this.handleRebaseEditTap(); | 
 |         break; | 
 |       case ChangeActions.INCLUDED_IN: | 
 |         this.handleIncludedInTap(); | 
 |         break; | 
 |       default: | 
 |         this.fireAction( | 
 |           this.prependSlash(key), | 
 |           assertUIActionInfo(this.actions[key]), | 
 |           false | 
 |         ); | 
 |     } | 
 |   } | 
 |  | 
 |   private handleRevisionAction(key: string) { | 
 |     switch (key) { | 
 |       case RevisionActions.REBASE: | 
 |         assertIsDefined(this.confirmRebase, 'confirmRebase'); | 
 |         this.showActionDialog(this.confirmRebase); | 
 |         this.confirmRebase.initiateFetchInfo(); | 
 |         break; | 
 |       case RevisionActions.CHERRYPICK: | 
 |         this.handleCherrypickTap(); | 
 |         break; | 
 |       case RevisionActions.DOWNLOAD: | 
 |         this.handleDownloadTap(); | 
 |         break; | 
 |       case RevisionActions.SUBMIT: | 
 |         if (!this.canSubmitChange()) { | 
 |           return; | 
 |         } | 
 |         assertIsDefined(this.confirmSubmitDialog, 'confirmSubmitDialog'); | 
 |         this.showActionDialog(this.confirmSubmitDialog); | 
 |         break; | 
 |       default: | 
 |         this.fireAction( | 
 |           this.prependSlash(key), | 
 |           assertUIActionInfo(this.revisionActions?.[key]), | 
 |           true | 
 |         ); | 
 |     } | 
 |   } | 
 |  | 
 |   private prependSlash(key: string) { | 
 |     return key === '/' ? key : `/${key}`; | 
 |   } | 
 |  | 
 |   private calculateDisabled(action: UIActionInfo) { | 
 |     // TODO(b/270972983): Remove this special casing once the backend is more | 
 |     // aggressive about setting`enabled:true`. | 
 |     if (action.__key === 'rebase') return false; | 
 |     return !action.enabled; | 
 |   } | 
 |  | 
 |   private handleConfirmDialogCancel() { | 
 |     this.hideAllDialogs(); | 
 |   } | 
 |  | 
 |   private hideAllDialogs() { | 
 |     assertIsDefined(this.confirmSubmitDialog, 'confirmSubmitDialog'); | 
 |     const dialogEls = queryAll(this, '.confirmDialog'); | 
 |     for (const dialogEl of dialogEls) { | 
 |       (dialogEl as HTMLElement).hidden = true; | 
 |     } | 
 |     assertIsDefined(this.actionsModal, 'actionsModal'); | 
 |     this.actionsModal.close(); | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) { | 
 |     assertIsDefined(this.confirmRebase, 'confirmRebase'); | 
 |     assertIsDefined(this.actionsModal, 'actionsModal'); | 
 |     const payload = { | 
 |       base: e.detail.base, | 
 |       allow_conflicts: e.detail.allowConflicts, | 
 |       on_behalf_of_uploader: e.detail.onBehalfOfUploader, | 
 |       committer_email: e.detail.committerEmail, | 
 |       validation_options: this.computeValidationOptionsForPayload( | 
 |         this.confirmRebase.getValidationOptions() | 
 |       ), | 
 |     }; | 
 |     const rebaseChain = !!e.detail.rebaseChain; | 
 |     this.fireAction( | 
 |       rebaseChain ? '/rebase:chain' : '/rebase', | 
 |       assertUIActionInfo(this.revisionActions?.rebase), | 
 |       rebaseChain ? false : true, | 
 |       payload, | 
 |       { | 
 |         allow_conflicts: payload.allow_conflicts, | 
 |         on_behalf_of_uploader: payload.on_behalf_of_uploader, | 
 |       } | 
 |     ); | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   handleCherrypickConfirm() { | 
 |     this.handleCherryPickRestApi(false); | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   handleCherrypickConflictConfirm() { | 
 |     this.handleCherryPickRestApi(true); | 
 |   } | 
 |  | 
 |   private handleCherryPickRestApi(conflicts: boolean) { | 
 |     assertIsDefined(this.confirmCherrypick, 'confirmCherrypick'); | 
 |     assertIsDefined(this.actionsModal, 'actionsModal'); | 
 |     const el = this.confirmCherrypick; | 
 |     if (!el.branch) { | 
 |       fireAlert(this, ERR_BRANCH_EMPTY); | 
 |       return; | 
 |     } | 
 |     if (!el.message) { | 
 |       fireAlert(this, ERR_COMMIT_EMPTY); | 
 |       return; | 
 |     } | 
 |     this.actionsModal.close(); | 
 |     el.hidden = true; | 
 |     this.fireAction( | 
 |       '/cherrypick', | 
 |       assertUIActionInfo(this.revisionActions?.cherrypick), | 
 |       true, | 
 |       { | 
 |         destination: el.branch, | 
 |         base: el.baseCommit ? el.baseCommit : null, | 
 |         message: el.message, | 
 |         allow_conflicts: conflicts, | 
 |         committer_email: el.committerEmail ? el.committerEmail : null, | 
 |       } | 
 |     ); | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   handleMoveConfirm() { | 
 |     assertIsDefined(this.confirmMove, 'confirmMove'); | 
 |     assertIsDefined(this.actionsModal, 'actionsModal'); | 
 |     const el = this.confirmMove; | 
 |     if (!el.branch) { | 
 |       fireAlert(this, ERR_BRANCH_EMPTY); | 
 |       return; | 
 |     } | 
 |     this.actionsModal.close(); | 
 |     el.hidden = true; | 
 |     this.fireAction('/move', assertUIActionInfo(this.actions.move), false, { | 
 |       destination_branch: el.branch, | 
 |       message: el.message, | 
 |     }); | 
 |   } | 
 |  | 
 |   private computeValidationOptionsForPayload(options: ValidationOptionInfo[]) { | 
 |     // https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-input | 
 |     // validation_options key defined as Map<string, string> here | 
 |     // This only works for options that expect a boolean "true" in return | 
 |     const validationOptionsMap: Record<string, string> = {}; | 
 |     for (const option of options) { | 
 |       validationOptionsMap[option.name] = 'true'; | 
 |     } | 
 |     return validationOptionsMap; | 
 |   } | 
 |  | 
 |   // private but visible for testing | 
 |   handleRevertDialogConfirm(e: CustomEvent<ConfirmRevertEventDetail>) { | 
 |     assertIsDefined(this.confirmRevertDialog, 'confirmRevertDialog'); | 
 |     assertIsDefined(this.actionsModal, 'actionsModal'); | 
 |     const revertType = e.detail.revertType; | 
 |     const message = e.detail.message; | 
 |     const el = this.confirmRevertDialog; | 
 |     this.actionsModal.close(); | 
 |     el.hidden = true; | 
 |     switch (revertType) { | 
 |       case RevertType.REVERT_SINGLE_CHANGE: | 
 |         this.fireAction( | 
 |           '/revert', | 
 |           assertUIActionInfo(this.actions.revert), | 
 |           false, | 
 |           { | 
 |             message, | 
 |             validation_options: this.computeValidationOptionsForPayload( | 
 |               this.confirmRevertDialog.getValidationOptions() | 
 |             ), | 
 |           } | 
 |         ); | 
 |         break; | 
 |       case RevertType.REVERT_SUBMISSION: | 
 |         // TODO(dhruvsri): replace with this.actions.revert_submission once | 
 |         // BE starts sending it again | 
 |         this.fireAction( | 
 |           '/revert_submission', | 
 |           {__key: 'revert_submission', method: HttpMethod.POST} as UIActionInfo, | 
 |           false, | 
 |           { | 
 |             message, | 
 |             validation_options: this.computeValidationOptionsForPayload( | 
 |               this.confirmRevertDialog.getValidationOptions() | 
 |             ), | 
 |           } | 
 |         ); | 
 |         break; | 
 |       default: | 
 |         this.reporting.error( | 
 |           'Change Actions', | 
 |           new Error('invalid revert type') | 
 |         ); | 
 |     } | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   handleAbandonDialogConfirm() { | 
 |     assertIsDefined(this.confirmAbandonDialog, 'confirmAbandonDialog'); | 
 |     assertIsDefined(this.actionsModal, 'actionsModal'); | 
 |     const el = this.confirmAbandonDialog; | 
 |     this.actionsModal.close(); | 
 |     el.hidden = true; | 
 |     this.fireAction( | 
 |       '/abandon', | 
 |       assertUIActionInfo(this.actions.abandon), | 
 |       false, | 
 |       { | 
 |         message: el.message, | 
 |       } | 
 |     ); | 
 |   } | 
 |  | 
 |   private handleCreateFollowUpChange() { | 
 |     assertIsDefined(this.createFollowUpChange, 'createFollowUpChange'); | 
 |     this.createFollowUpChange.handleCreateChange(); | 
 |     this.handleCloseCreateFollowUpChange(); | 
 |   } | 
 |  | 
 |   private handleCloseCreateFollowUpChange() { | 
 |     assertIsDefined(this.actionsModal, 'actionsModal'); | 
 |     this.actionsModal.close(); | 
 |   } | 
 |  | 
 |   private handleDeleteConfirm() { | 
 |     this.hideAllDialogs(); | 
 |     this.fireAction( | 
 |       '/', | 
 |       assertUIActionInfo(this.actions[ChangeActions.DELETE]), | 
 |       false | 
 |     ); | 
 |   } | 
 |  | 
 |   private handleDeleteEditConfirm() { | 
 |     this.hideAllDialogs(); | 
 |  | 
 |     // We need to make sure that all cached version of a change | 
 |     // edit are deleted. | 
 |     this.getStorage().eraseEditableContentItemsForChangeEdit(this.changeNum); | 
 |  | 
 |     this.fireAction( | 
 |       '/edit', | 
 |       assertUIActionInfo(this.actions.deleteEdit), | 
 |       false | 
 |     ); | 
 |   } | 
 |  | 
 |   private handlePublishEditConfirm() { | 
 |     this.hideAllDialogs(); | 
 |  | 
 |     if (!this.actions.publishEdit) return; | 
 |  | 
 |     // We need to make sure that all cached version of a change | 
 |     // edit are deleted. | 
 |     this.getStorage().eraseEditableContentItemsForChangeEdit(this.changeNum); | 
 |  | 
 |     this.fireAction( | 
 |       '/edit:publish', | 
 |       assertUIActionInfo(this.actions.publishEdit), | 
 |       false, | 
 |       {notify: NotifyType.OWNER_REVIEWERS} | 
 |     ); | 
 |     this.sendPublishEditEvent(); | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   handleSubmitConfirm() { | 
 |     if (!this.canSubmitChange()) { | 
 |       return; | 
 |     } | 
 |     this.hideAllDialogs(); | 
 |     this.fireAction( | 
 |       '/submit', | 
 |       assertUIActionInfo(this.revisionActions?.submit), | 
 |       true | 
 |     ); | 
 |   } | 
 |  | 
 |   private isOverflowAction(type: string, key: string) { | 
 |     return this.overflowActions.some( | 
 |       action => action.type === type && action.key === key | 
 |     ); | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   setLoadingOnButtonWithKey(action: UIActionInfo) { | 
 |     const key = action.__key; | 
 |     this.inProgressActionKeys.add(key); | 
 |     this.actionLoadingMessage = this.computeLoadingLabel(key); | 
 |     let buttonKey = key; | 
 |     // TODO(dhruvsri): clean this up later | 
 |     // If key is revert-submission, then button key should be 'revert' | 
 |     if (buttonKey === ChangeActions.REVERT_SUBMISSION) { | 
 |       // Revert submission button no longer exists | 
 |       buttonKey = ChangeActions.REVERT; | 
 |     } | 
 |  | 
 |     if (this.isOverflowAction(action.__type, buttonKey)) { | 
 |       this.disabledMenuActions.push( | 
 |         buttonKey === '/' ? 'delete-change' : `${buttonKey}-${action.__type}` | 
 |       ); | 
 |       this.requestUpdate('disabledMenuActions'); | 
 |       return () => { | 
 |         this.inProgressActionKeys.delete(key); | 
 |         this.actionLoadingMessage = ''; | 
 |         this.disabledMenuActions = []; | 
 |         this.requestUpdate(); | 
 |       }; | 
 |     } | 
 |  | 
 |     // Otherwise it's a top-level action. | 
 |     const buttonEl = this.shadowRoot!.querySelector( | 
 |       `[data-action-key="${buttonKey}"]` | 
 |     ) as GrButton; | 
 |     if (!buttonEl) { | 
 |       throw new Error(`Can't find button by data-action-key '${buttonKey}'`); | 
 |     } | 
 |     buttonEl.setAttribute('loading', 'true'); | 
 |     buttonEl.disabled = true; | 
 |     return () => { | 
 |       this.inProgressActionKeys.delete(action.__key); | 
 |       this.actionLoadingMessage = ''; | 
 |       buttonEl.removeAttribute('loading'); | 
 |       buttonEl.disabled = false; | 
 |       this.requestUpdate(); | 
 |     }; | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   fireAction( | 
 |     endpoint: string, | 
 |     action: UIActionInfo, | 
 |     revAction: boolean, | 
 |     payload?: RequestPayload, | 
 |     toReport?: object | 
 |   ) { | 
 |     const cleanupFn = this.setLoadingOnButtonWithKey(action); | 
 |     this.reporting.reportInteraction(Interaction.CHANGE_ACTION_FIRED, { | 
 |       endpoint, | 
 |       toReport, | 
 |     }); | 
 |  | 
 |     this.send( | 
 |       action.method, | 
 |       payload, | 
 |       endpoint, | 
 |       revAction, | 
 |       cleanupFn, | 
 |       action | 
 |     ).then(res => this.handleResponse(action, res)); | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   showActionDialog(dialog: ChangeActionDialog) { | 
 |     this.hideAllDialogs(); | 
 |     if (dialog.init) dialog.init(); | 
 |     dialog.hidden = false; | 
 |     assertIsDefined(this.actionsModal, 'actionsModal'); | 
 |     if (this.actionsModal.isConnected) this.actionsModal.showModal(); | 
 |     whenVisible(dialog, () => { | 
 |       if (dialog.resetFocus) { | 
 |         dialog.resetFocus(); | 
 |       } | 
 |     }); | 
 |   } | 
 |  | 
 |   // TODO(rmistry): Redo this after | 
 |   // https://issues.gerritcodereview.com/issues/40004936 is resolved. | 
 |   // private but used in test | 
 |   setReviewOnRevert(newChangeId: NumericChangeId) { | 
 |     const review = this.getPluginLoader().jsApiService.getReviewPostRevert( | 
 |       this.change as ChangeInfo | 
 |     ); | 
 |     if (!review) { | 
 |       return Promise.resolve(undefined); | 
 |     } | 
 |     return this.restApiService.saveChangeReview(newChangeId, CURRENT, review); | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   async handleResponse(action: UIActionInfo, response: Response | undefined) { | 
 |     if (!response?.ok) { | 
 |       return; | 
 |     } | 
 |     switch (action.__key) { | 
 |       case ChangeActions.REVERT: { | 
 |         const revertChangeInfo = (await readJSONResponsePayload(response)) | 
 |           .parsed as unknown as ChangeInfo; | 
 |         this.restApiService.addRepoNameToCache( | 
 |           revertChangeInfo._number, | 
 |           revertChangeInfo.project | 
 |         ); | 
 |         const reachable = await this.waitForChangeReachable( | 
 |           revertChangeInfo._number | 
 |         ); | 
 |         if (!reachable) return; | 
 |         await this.setReviewOnRevert(revertChangeInfo._number); | 
 |         this.getNavigation().setUrl( | 
 |           createChangeUrl({change: revertChangeInfo}) | 
 |         ); | 
 |         break; | 
 |       } | 
 |       case RevisionActions.CHERRYPICK: { | 
 |         const cherrypickChangeInfo = (await readJSONResponsePayload(response)) | 
 |           .parsed as unknown as ChangeInfo; | 
 |         this.restApiService.addRepoNameToCache( | 
 |           cherrypickChangeInfo._number, | 
 |           cherrypickChangeInfo.project | 
 |         ); | 
 |         const reachable = this.waitForChangeReachable( | 
 |           cherrypickChangeInfo._number | 
 |         ); | 
 |         if (!reachable) return; | 
 |         this.getNavigation().setUrl( | 
 |           createChangeUrl({change: cherrypickChangeInfo}) | 
 |         ); | 
 |         break; | 
 |       } | 
 |       case ChangeActions.DELETE: | 
 |         if (action.__type === ActionType.CHANGE) { | 
 |           this.getNavigation().setUrl(rootUrl()); | 
 |         } | 
 |         break; | 
 |       case ChangeActions.WIP: | 
 |       case ChangeActions.DELETE_EDIT: | 
 |       case ChangeActions.PUBLISH_EDIT: | 
 |       case ChangeActions.REBASE_EDIT: | 
 |       case ChangeActions.REBASE: | 
 |       case ChangeActions.SUBMIT: | 
 |         // Hide rebase dialog only if the action succeeds | 
 |         this.actionsModal?.close(); | 
 |         this.hideAllDialogs(); | 
 |         this.getChangeModel().navigateToChangeResetReload(); | 
 |         break; | 
 |       case ChangeActions.REVERT_SUBMISSION: { | 
 |         const revertSubmistionInfo = (await readJSONResponsePayload(response)) | 
 |           .parsed as unknown as RevertSubmissionInfo; | 
 |         if ( | 
 |           !revertSubmistionInfo.revert_changes || | 
 |           !revertSubmistionInfo.revert_changes.length | 
 |         ) | 
 |           return; | 
 |         /* If there is only 1 change then gerrit will automatically | 
 |           redirect to that change */ | 
 |         const topic = revertSubmistionInfo.revert_changes[0].topic; | 
 |         this.getNavigation().setUrl(createSearchUrl({topic})); | 
 |         break; | 
 |       } | 
 |       default: | 
 |         this.getChangeModel().navigateToChangeResetReload(); | 
 |         break; | 
 |     } | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   handleResponseError( | 
 |     action: UIActionInfo, | 
 |     response: Response | undefined | null, | 
 |     body?: RequestPayload | 
 |   ) { | 
 |     if (!response) { | 
 |       return Promise.resolve(() => { | 
 |         fireError(this, `Could not perform action '${action.__key}'`); | 
 |       }); | 
 |     } | 
 |     if (action && action.__key === RevisionActions.CHERRYPICK) { | 
 |       if ( | 
 |         response.status === 409 && | 
 |         body && | 
 |         !(body as CherryPickInput).allow_conflicts | 
 |       ) { | 
 |         assertIsDefined( | 
 |           this.confirmCherrypickConflict, | 
 |           'confirmCherrypickConflict' | 
 |         ); | 
 |         this.showActionDialog(this.confirmCherrypickConflict); | 
 |         return; | 
 |       } | 
 |     } | 
 |     return response.text().then(errText => { | 
 |       fireError(this, `Could not perform action: ${errText}`); | 
 |       if (!errText.startsWith('Change is already up to date')) { | 
 |         throw Error(errText); | 
 |       } | 
 |     }); | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   send( | 
 |     method: HttpMethod | undefined, | 
 |     payload: RequestPayload | undefined, | 
 |     actionEndpoint: string, | 
 |     revisionAction: boolean, | 
 |     cleanupFn: () => void, | 
 |     action: UIActionInfo | 
 |   ): Promise<Response | undefined> { | 
 |     const handleError: ErrorCallback = response => { | 
 |       cleanupFn.call(this); | 
 |       this.handleResponseError(action, response, payload); | 
 |     }; | 
 |     const change = this.change; | 
 |     const changeNum = this.changeNum; | 
 |     if (!change || !changeNum) { | 
 |       return Promise.reject( | 
 |         new Error('Properties change and changeNum must be set.') | 
 |       ); | 
 |     } | 
 |     return this.getChangeModel() | 
 |       .fetchChangeUpdates(change) | 
 |       .then(result => { | 
 |         if (!result.isLatest) { | 
 |           fire(this, 'show-alert', { | 
 |             message: | 
 |               'Cannot set label: a newer patch has been ' + | 
 |               'uploaded to this change.', | 
 |             action: 'Reload', | 
 |             callback: () => this.getChangeModel().navigateToChangeResetReload(), | 
 |           }); | 
 |  | 
 |           // Because this is not a network error, call the cleanup function | 
 |           // but not the error handler. | 
 |           cleanupFn(); | 
 |  | 
 |           return Promise.resolve(undefined); | 
 |         } | 
 |         const patchNum = revisionAction ? this.latestPatchNum : undefined; | 
 |         return this.restApiService | 
 |           .executeChangeAction( | 
 |             changeNum, | 
 |             method, | 
 |             actionEndpoint, | 
 |             patchNum, | 
 |             payload, | 
 |             handleError | 
 |           ) | 
 |           .then(response => { | 
 |             cleanupFn.call(this); | 
 |             return response; | 
 |           }); | 
 |       }); | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   async handleCherrypickTap() { | 
 |     if (!this.change) { | 
 |       throw new Error('The change property must be set'); | 
 |     } | 
 |     assertIsDefined(this.confirmCherrypick, 'confirmCherrypick'); | 
 |     this.confirmCherrypick.branch = '' as BranchName; | 
 |     const changes = await this.getCherryPickChanges(); | 
 |     if (!changes.length) return; | 
 |     this.confirmCherrypick.updateChanges(changes); | 
 |     this.showActionDialog(this.confirmCherrypick); | 
 |   } | 
 |  | 
 |   private async getCherryPickChanges() { | 
 |     if (!this.change) return []; | 
 |     if (!this.change.topic) return [this.change]; | 
 |     const query = `topic: "${this.change.topic}"`; | 
 |     const options = listChangesOptionsToHex( | 
 |       ListChangesOption.MESSAGES, | 
 |       ListChangesOption.ALL_REVISIONS | 
 |     ); | 
 |     return this.restApiService | 
 |       .getChanges(0, query, undefined, options) | 
 |       .then(changes => { | 
 |         if (!changes) { | 
 |           this.reporting.error( | 
 |             'Change Actions', | 
 |             new Error('getChanges returns undefined') | 
 |           ); | 
 |           return []; | 
 |         } | 
 |         return changes; | 
 |       }); | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   handleMoveTap() { | 
 |     assertIsDefined(this.confirmMove, 'confirmMove'); | 
 |     this.confirmMove.branch = '' as BranchName; | 
 |     this.confirmMove.message = ''; | 
 |     this.showActionDialog(this.confirmMove); | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   handleDownloadTap() { | 
 |     fire(this, 'download-tap', {}); | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   handleIncludedInTap() { | 
 |     fire(this, 'included-tap', {}); | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   handleDeleteTap() { | 
 |     assertIsDefined(this.confirmDeleteDialog, 'confirmDeleteDialog'); | 
 |     this.showActionDialog(this.confirmDeleteDialog); | 
 |   } | 
 |  | 
 |   // private but used in test | 
 |   handleDeleteEditTap() { | 
 |     assertIsDefined(this.confirmDeleteEditDialog, 'confirmDeleteEditDialog'); | 
 |     this.showActionDialog(this.confirmDeleteEditDialog); | 
 |   } | 
 |  | 
 |   private handleFollowUpTap() { | 
 |     assertIsDefined(this.createFollowUpDialog, 'createFollowUpDialog'); | 
 |     this.showActionDialog(this.createFollowUpDialog); | 
 |   } | 
 |  | 
 |   private handleWipTap() { | 
 |     if (!this.actions.wip) { | 
 |       return; | 
 |     } | 
 |     this.fireAction('/wip', assertUIActionInfo(this.actions.wip), false); | 
 |   } | 
 |  | 
 |   private handlePublishEditTap() { | 
 |     if (this.numberOfThreadsWithUnappliedSuggestions() > 0) { | 
 |       assertIsDefined( | 
 |         this.confirmPublishEditDialog, | 
 |         'confirmPublishEditDialog' | 
 |       ); | 
 |       this.showActionDialog(this.confirmPublishEditDialog); | 
 |     } else { | 
 |       // Skip confirmation dialog and publish immediately. | 
 |       this.handlePublishEditConfirm(); | 
 |     } | 
 |   } | 
 |  | 
 |   private handleRebaseEditTap() { | 
 |     if (!this.actions.rebaseEdit) { | 
 |       return; | 
 |     } | 
 |     this.fireAction( | 
 |       '/edit:rebase', | 
 |       assertUIActionInfo(this.actions.rebaseEdit), | 
 |       false | 
 |     ); | 
 |   } | 
 |  | 
 |   /** | 
 |    * Merge sources of change actions into a single ordered array of action | 
 |    * values. | 
 |    */ | 
 |   private computeAllActions(): UIActionInfo[] { | 
 |     if (this.change === undefined) { | 
 |       return []; | 
 |     } | 
 |  | 
 |     const revisionActionValues = this.getActionValues( | 
 |       this.revisionActions, | 
 |       this.primaryActionKeys, | 
 |       this.additionalActions, | 
 |       ActionType.REVISION | 
 |     ); | 
 |     const changeActionValues = this.getActionValues( | 
 |       this.actions, | 
 |       this.primaryActionKeys, | 
 |       this.additionalActions, | 
 |       ActionType.CHANGE | 
 |     ); | 
 |     const quickApprove = this.getQuickApproveAction(); | 
 |     if (quickApprove) { | 
 |       changeActionValues.unshift(quickApprove); | 
 |     } | 
 |  | 
 |     return revisionActionValues | 
 |       .concat(changeActionValues) | 
 |       .sort((a, b) => this.actionComparator(a, b)) | 
 |       .map(action => { | 
 |         return { | 
 |           ...action, | 
 |           ...(ACTIONS_WITH_ICONS.get(action.__key) ?? {}), | 
 |         }; | 
 |       }) | 
 |       .filter(action => !this.shouldSkipAction(action)); | 
 |   } | 
 |  | 
 |   private getActionPriority(action: UIActionInfo) { | 
 |     if (action.__type && action.__key) { | 
 |       const overrideAction = this.actionPriorityOverrides.find( | 
 |         i => i.type === action.__type && i.key === action.__key | 
 |       ); | 
 |  | 
 |       if (overrideAction !== undefined) { | 
 |         return overrideAction.priority; | 
 |       } | 
 |     } | 
 |     if (action.__key === 'review') { | 
 |       return ActionPriority.REVIEW; | 
 |     } else if (action.__primary) { | 
 |       return ActionPriority.PRIMARY; | 
 |     } else if (action.__type === ActionType.CHANGE) { | 
 |       return ActionPriority.CHANGE; | 
 |     } else if (action.__type === ActionType.REVISION) { | 
 |       return ActionPriority.REVISION; | 
 |     } | 
 |     return ActionPriority.DEFAULT; | 
 |   } | 
 |  | 
 |   /** | 
 |    * Sort comparator to define the order of change actions. | 
 |    * | 
 |    * private but used in test | 
 |    */ | 
 |   actionComparator(actionA: UIActionInfo, actionB: UIActionInfo) { | 
 |     const priorityDelta = | 
 |       this.getActionPriority(actionA) - this.getActionPriority(actionB); | 
 |     // Sort by the button label if same priority. | 
 |     if (priorityDelta === 0) { | 
 |       return actionA.label > actionB.label ? 1 : -1; | 
 |     } else { | 
 |       return priorityDelta; | 
 |     } | 
 |   } | 
 |  | 
 |   private shouldSkipAction(action: UIActionInfo) { | 
 |     return SKIP_ACTION_KEYS.includes(action.__key); | 
 |   } | 
 |  | 
 |   private computeMenuActions(): MenuAction[] { | 
 |     return this.allActionValues | 
 |       .filter(a => { | 
 |         const overflow = this.isOverflowAction(a.__type, a.__key); | 
 |         return overflow && !this.hiddenActions.includes(a.__key); | 
 |       }) | 
 |       .map(action => { | 
 |         let key = action.__key; | 
 |         if (key === '/') { | 
 |           key = 'delete'; | 
 |         } | 
 |         return { | 
 |           name: action.label, | 
 |           id: `${key}-${action.__type}`, | 
 |           action, | 
 |           tooltip: action.title, | 
 |         }; | 
 |       }); | 
 |   } | 
 |  | 
 |   /** | 
 |    * Occasionally, a change created by a change action is not yet known to the | 
 |    * API for a brief time. Wait for the given change number to be recognized. | 
 |    * | 
 |    * Returns a promise that resolves with true if a request is recognized, or | 
 |    * false if the change was never recognized after all attempts. | 
 |    * | 
 |    * private but used in test | 
 |    */ | 
 |   waitForChangeReachable(changeNum: NumericChangeId): Promise<boolean> { | 
 |     let attemptsRemaining = AWAIT_CHANGE_ATTEMPTS; | 
 |     return new Promise(resolve => { | 
 |       const check = () => { | 
 |         attemptsRemaining--; | 
 |         // Pass a no-op error handler to avoid the "not found" error toast, | 
 |         // unless it's the last attempt | 
 |         this.restApiService | 
 |           .getChange(changeNum, attemptsRemaining !== 0 ? () => {} : undefined) | 
 |           .then(response => { | 
 |             // If the response is 404, the response will be undefined. | 
 |             if (response) { | 
 |               resolve(true); | 
 |               return; | 
 |             } | 
 |  | 
 |             if (attemptsRemaining) { | 
 |               setTimeout(check, AWAIT_CHANGE_TIMEOUT_MS); | 
 |             } else { | 
 |               resolve(false); | 
 |             } | 
 |           }); | 
 |       }; | 
 |       check(); | 
 |     }); | 
 |   } | 
 |  | 
 |   private handleEditTap() { | 
 |     fireNoBubbleNoCompose(this, 'edit-tap', {}); | 
 |   } | 
 |  | 
 |   private handleStopEditTap() { | 
 |     fireNoBubbleNoCompose(this, 'stop-edit-tap', {}); | 
 |   } | 
 |  | 
 |   private numberOfThreadsWithUnappliedSuggestions() { | 
 |     if (!this.threadsWithUnappliedSuggestions) return 0; | 
 |     return this.threadsWithUnappliedSuggestions.length; | 
 |   } | 
 | } | 
 |  | 
 | declare global { | 
 |   interface HTMLElementEventMap { | 
 |     'download-tap': CustomEvent<{}>; | 
 |     'edit-tap': CustomEvent<{}>; | 
 |     'included-tap': CustomEvent<{}>; | 
 |     'revision-actions-changed': CustomEvent<{value: ActionNameToActionInfoMap}>; | 
 |     'stop-edit-tap': CustomEvent<{}>; | 
 |   } | 
 |   interface HTMLElementTagNameMap { | 
 |     'gr-change-actions': GrChangeActions; | 
 |   } | 
 | } |