| /** |
| * @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 { |
| AccountInfo, |
| BasePatchSetNum, |
| RevisionPatchSetNum, |
| } from '@gerritcodereview/typescript-api/rest-api'; |
| import {css, html, LitElement, nothing, PropertyValues} from 'lit'; |
| import {customElement, property} from 'lit/decorators'; |
| import {OwnerStatus} from './code-owners-api'; |
| import {FileStatus, PluginState} from './code-owners-model'; |
| import {CodeOwnersModelMixin} from './code-owners-model-mixin'; |
| |
| // TODO: Extend the API for plugins. |
| export interface PatchRange { |
| patchNum: RevisionPatchSetNum; |
| basePatchNum: BasePatchSetNum; |
| } |
| |
| const ACCOUNT_TEMPLATE_REGEX = '<GERRIT_ACCOUNT_(\\d+)>'; |
| |
| const MAGIC_FILES = ['/COMMIT_MSG', '/MERGE_LIST', '/PATCHSET_LEVEL']; |
| const STATUS_CODE = { |
| NO_STATUS: 'no-status', |
| PENDING: 'pending', |
| PENDING_OLD_PATH: 'pending-old-path', |
| MISSING: 'missing', |
| MISSING_OLD_PATH: 'missing-old-path', |
| APPROVED: 'approved', |
| }; |
| |
| const STATUS_PRIORITY_ORDER = [ |
| STATUS_CODE.NO_STATUS, |
| STATUS_CODE.MISSING, |
| STATUS_CODE.PENDING, |
| STATUS_CODE.MISSING_OLD_PATH, |
| STATUS_CODE.PENDING_OLD_PATH, |
| STATUS_CODE.APPROVED, |
| ]; |
| |
| const STATUS_ICON = { |
| [STATUS_CODE.PENDING]: 'schedule', |
| [STATUS_CODE.MISSING]: 'close', |
| [STATUS_CODE.PENDING_OLD_PATH]: 'schedule', |
| [STATUS_CODE.MISSING_OLD_PATH]: 'close', |
| [STATUS_CODE.APPROVED]: 'check', |
| [STATUS_CODE.NO_STATUS]: 'check_circle', |
| }; |
| |
| const STATUS_SUMMARY = { |
| [STATUS_CODE.PENDING]: 'Pending', |
| [STATUS_CODE.MISSING]: 'Missing', |
| [STATUS_CODE.PENDING_OLD_PATH]: 'Pending Old Path', |
| [STATUS_CODE.MISSING_OLD_PATH]: 'Missing Old Path', |
| [STATUS_CODE.APPROVED]: 'Approved', |
| [STATUS_CODE.NO_STATUS]: 'Does not need approval', |
| }; |
| |
| const STATUS_TOOLTIP = { |
| [STATUS_CODE.PENDING]: 'Pending code owner approval', |
| [STATUS_CODE.MISSING]: 'Missing code owner approval', |
| [STATUS_CODE.PENDING_OLD_PATH]: |
| 'Pending code owner approval on pre-renamed file', |
| [STATUS_CODE.MISSING_OLD_PATH]: |
| 'Missing code owner approval on pre-renamed file', |
| [STATUS_CODE.APPROVED]: 'Approved by code owner', |
| [STATUS_CODE.NO_STATUS]: 'Does not need approval', |
| }; |
| |
| export function hasPath(ownedPaths: Set<string>, path: string | undefined) { |
| if (!path) return false; |
| if (path.charAt(0) !== '/') path = '/' + path; |
| return ownedPaths.has(path); |
| } |
| |
| export function getOwners( |
| owners: Map<string, Array<AccountInfo>>, |
| path: string | undefined |
| ): Array<AccountInfo> { |
| if (!path) return []; |
| if (path.charAt(0) !== '/') path = '/' + path; |
| return owners.get(path) ?? []; |
| } |
| |
| export function uniqueAccountId( |
| account: AccountInfo, |
| index: number, |
| accountArray: AccountInfo[] |
| ) { |
| return ( |
| index === |
| accountArray.findIndex(other => account._account_id === other._account_id) |
| ); |
| } |
| |
| const base = CodeOwnersModelMixin(LitElement); |
| |
| class BaseEl extends base { |
| @property({type: Object}) |
| patchRange?: PatchRange; |
| |
| protected override willUpdate(changedProperties: PropertyValues): void { |
| super.willUpdate(changedProperties); |
| this.hidden = this.computeHidden(); |
| } |
| |
| computeHidden() { |
| const newerPatchsetUploaded = this.status?.newerPatchsetUploaded; |
| if ( |
| this.change === undefined || |
| this.patchRange === undefined || |
| newerPatchsetUploaded === undefined |
| ) { |
| return true; |
| } |
| if (this.pluginStatus?.state !== PluginState.Enabled) return true; |
| if (this.change.status === 'MERGED') return true; |
| // if code-owners is not a submit requirement, don't show status column |
| if ( |
| this.change.requirements && |
| !this.change.requirements.find(r => r.type === 'code-owners') |
| ) { |
| return false; |
| } |
| |
| if (newerPatchsetUploaded) return true; |
| |
| const latestPatchset = |
| this.change.revisions![this.change.current_revision!]; |
| // Note: in some special cases, patchNum is undefined on latest patchset |
| // like after publishing the edit, still show for them |
| // TODO: this should be fixed in Gerrit |
| if (this.patchRange?.patchNum === undefined) return false; |
| // only show if its latest patchset |
| if (`${this.patchRange.patchNum}` !== `${latestPatchset._number}`) |
| return true; |
| return false; |
| } |
| } |
| |
| export const OWNERS_STATUS_COLUMN_HEADER = 'owner-status-column-header'; |
| /** |
| * Column header element for owner status. |
| */ |
| @customElement(OWNERS_STATUS_COLUMN_HEADER) |
| export class OwnerStatusColumnHeader extends BaseEl { |
| static override get styles() { |
| return [ |
| css` |
| :host() { |
| display: block; |
| padding-right: var(--spacing-m); |
| width: 5em; |
| } |
| :host[hidden] { |
| display: none; |
| } |
| `, |
| ]; |
| } |
| |
| override render() { |
| if (this.computeHidden()) return nothing; |
| return html`<div>Owners</div>`; |
| } |
| } |
| |
| export const OWNER_STATUS_COLUMN_CONTENT = 'owner-status-column-content'; |
| /** |
| * Row content element for owner status. |
| */ |
| @customElement(OWNER_STATUS_COLUMN_CONTENT) |
| export class OwnerStatusColumnContent extends BaseEl { |
| @property({type: String}) |
| path?: string; |
| |
| @property({type: String}) |
| oldPath?: string; |
| |
| @property({type: Array}) |
| cleanlyMergedPaths?: Array<string>; |
| |
| @property({type: Array}) |
| cleanlyMergedOldPaths?: Array<string>; |
| |
| @property({type: String, reflect: true, attribute: 'owner-status'}) |
| ownerStatus?: string; |
| |
| @property({type: Array}) |
| ownerReasons?: Array<string>; |
| |
| static override get styles() { |
| return [ |
| css` |
| :host { |
| display: flex; |
| padding-right: var(--spacing-m); |
| width: 5em; |
| text-align: center; |
| } |
| :host[hidden] { |
| display: none; |
| } |
| gr-icon { |
| padding: var(--spacing-xs) 0px; |
| } |
| :host([owner-status='approved']) gr-icon.status { |
| color: var(--positive-green-text-color); |
| } |
| :host([owner-status='pending']) gr-icon.status { |
| color: #ffa62f; |
| } |
| :host([owner-status='missing']) gr-icon.status { |
| color: var(--negative-red-text-color); |
| } |
| gr-avatar-stack { |
| padding: var(--spacing-xs) 0px; |
| display: flex; |
| --avatar-size: 20px; |
| } |
| .ellipsis { |
| /* These are required to get the ... to line up with the bottom of |
| the avatar icons. */ |
| margin-bottom: -2px; |
| display: flex; |
| align-items: flex-end; |
| } |
| .error { |
| color: var(--negative-red-text-color); |
| } |
| .fallback-icon { |
| /* Undo the padding for the gr-avatar-stack in case of fallback */ |
| padding: calc(-1 * var(--spacing-xs)) 0px; |
| } |
| `, |
| ]; |
| } |
| |
| protected override willUpdate(changedProperties: PropertyValues): void { |
| super.willUpdate(changedProperties); |
| this.computeStatus(); |
| } |
| |
| private renderReason(reason: string): string { |
| let reasonWithAccounts = reason.replace( |
| new RegExp(ACCOUNT_TEMPLATE_REGEX, 'g'), |
| (_accountIdTemplate, accountId) => { |
| const parsedAccountId = Number(accountId); |
| const accountInText = (this.status?.accounts || {})[parsedAccountId]; |
| if (!accountInText) { |
| return `Gerrit Account ${parsedAccountId}`; |
| } |
| return accountInText.display_name ?? accountInText.name ?? ''; |
| } |
| ); |
| return ( |
| reasonWithAccounts.charAt(0).toUpperCase() + reasonWithAccounts.slice(1) |
| ); |
| } |
| |
| override render() { |
| if ( |
| this.computeHidden() || |
| this.status === undefined || |
| !this.path || |
| MAGIC_FILES.includes(this.path) |
| ) |
| return nothing; |
| return html`${this.renderStatus()}${this.renderOwnership()}`; |
| } |
| |
| private renderStatus() { |
| let info = STATUS_TOOLTIP[this.ownerStatus!]; |
| if (this.ownerReasons) { |
| info = this.ownerReasons.map(r => this.renderReason(r)).join('\n'); |
| } |
| const summary = STATUS_SUMMARY[this.ownerStatus!]; |
| const icon = STATUS_ICON[this.ownerStatus!]; |
| return html` |
| <gr-tooltip-content |
| title=${info} |
| aria-label=${summary} |
| aria-description=${info} |
| has-tooltip |
| > |
| <gr-icon class="status" icon=${icon} aria-hidden="true"></gr-icon> |
| </gr-tooltip-content> |
| `; |
| } |
| |
| private renderOwnership() { |
| if (this.isOwned()) { |
| return html` |
| <gr-tooltip-content |
| title="You are in OWNERS for this file" |
| aria-label="owned" |
| aria-description="You are an owner of this file" |
| has-tooltip |
| > |
| <gr-icon filled icon="policy" aria-hidden="true"></gr-icon> |
| </gr-tooltip-content> |
| `; |
| } else if (this.ownedPaths) { |
| let oldOwners = getOwners(this.ownedPaths.oldPathOwners, this.oldPath); |
| const newOwners = getOwners(this.ownedPaths.newPathOwners, this.path); |
| if (this.oldPath === undefined || this.oldPath === null) { |
| // In case of a file deletion, the Gerrit FE gives 'path' but not 'oldPath' |
| // but code-owners considers a deleted file an oldpath so check the oldpath owners. |
| oldOwners = getOwners(this.ownedPaths.oldPathOwners, this.path); |
| } |
| const allOwners = oldOwners.concat(newOwners).filter(uniqueAccountId); |
| |
| return html` <gr-avatar-stack |
| .accounts=${allOwners.slice(0, 3)} |
| .forceFetch=${true} |
| .enableHover=${true} |
| > |
| <gr-tooltip-content |
| slot="fallback" |
| title="No reviewer owns this file" |
| aria-label="missing owner" |
| aria-description="No reviewer owns this file" |
| has-tooltip |
| > |
| <gr-icon icon="help" class="error fallback-icon"></gr-icon> |
| </gr-tooltip-content> |
| </gr-avatar-stack> |
| ${allOwners.length > 3 |
| ? html`<div class="ellipsis">…</div>` |
| : nothing}`; |
| } |
| return nothing; |
| } |
| |
| private isOwned() { |
| if (!this.ownedPaths) return false; |
| if ( |
| hasPath(this.ownedPaths.newPaths, this.path) || |
| hasPath(this.ownedPaths.oldPaths, this.oldPath) || |
| // In case of deletions, the FE gives a path, but code-owners |
| // computes this as being part of the old path. |
| ((this.oldPath === undefined || this.oldPath === null) && |
| hasPath(this.ownedPaths.oldPaths, this.path)) |
| ) |
| return true; |
| return false; |
| } |
| |
| override loadPropertiesAfterModelChanged() { |
| super.loadPropertiesAfterModelChanged(); |
| this.modelLoader?.loadStatus(); |
| this.modelLoader?.loadOwnedPaths(); |
| } |
| |
| private computeStatus() { |
| if ( |
| this.status === undefined || |
| (this.cleanlyMergedPaths === undefined && |
| (this.path === undefined || this.oldPath === undefined)) |
| ) { |
| return; |
| } |
| |
| const codeOwnerStatusMap = this.status.codeOwnerStatusMap; |
| const paths = |
| this.path === undefined ? this.cleanlyMergedPaths : [this.path]; |
| const oldPaths = |
| this.oldPath === undefined ? this.cleanlyMergedOldPaths : [this.oldPath]; |
| |
| const statuses = (paths ?? []) |
| .filter(path => !MAGIC_FILES.includes(path)) |
| .map(path => ({ |
| status: this.extractStatus(codeOwnerStatusMap.get(path), false), |
| reasons: codeOwnerStatusMap.get(path)?.reasons, |
| })); |
| // oldPath may contain null, so filter that as well. |
| const oldStatuses = (oldPaths ?? []) |
| .filter(path => !MAGIC_FILES.includes(path) && !!path) |
| .map(path => ({ |
| status: this.extractStatus(codeOwnerStatusMap.get(path), true), |
| reasons: codeOwnerStatusMap.get(path)?.reasons, |
| })); |
| const allStatuses = statuses.concat(oldStatuses); |
| if (allStatuses.length === 0) { |
| return; |
| } |
| const computedStatus = allStatuses.reduce((a, b) => |
| STATUS_PRIORITY_ORDER.indexOf(a.status) < |
| STATUS_PRIORITY_ORDER.indexOf(b.status) |
| ? a |
| : b |
| ); |
| this.ownerStatus = computedStatus.status; |
| this.ownerReasons = computedStatus.reasons; |
| } |
| |
| private extractStatus(statusItem: FileStatus | undefined, oldPath: boolean) { |
| if (statusItem === undefined) { |
| return STATUS_CODE.NO_STATUS; |
| } else if (statusItem.status === OwnerStatus.INSUFFICIENT_REVIEWERS) { |
| return oldPath ? STATUS_CODE.MISSING_OLD_PATH : STATUS_CODE.MISSING; |
| } else if (statusItem.status === OwnerStatus.PENDING) { |
| return oldPath ? STATUS_CODE.PENDING_OLD_PATH : STATUS_CODE.PENDING; |
| } else { |
| return STATUS_CODE.APPROVED; |
| } |
| } |
| } |