blob: b13594dfbb4f4389b777b924a8dedc92baa69ca8 [file] [edit]
/**
* @license
* Copyright (C) 2024 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 {css, html, LitElement, nothing, PropertyValues, CSSResult} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {
AccountInfo,
ApprovalInfo,
ChangeInfo,
ChangeStatus,
GroupInfo,
LabelInfo,
isDetailedLabelInfo,
EmailAddress,
ReviewerState,
} from '@gerritcodereview/typescript-api/rest-api';
import {
FilesOwners,
isOwner,
OwnersLabels,
hasOwnersSubmitRequirement,
} from './owners-service';
import {
FileOwnership,
FileStatus,
OwnerOrGroupOwner,
PatchRange,
UserRole,
} from './owners-model';
import {query} from './utils';
import {GrAccountLabel} from './gerrit-model';
import {OwnersMixin} from './owners-mixin';
const STATUS_CODE = {
MISSING: 'missing',
APPROVED: 'approved',
AUTO_APPROVED: 'autoApproved',
};
const STATUS_ICON = {
[STATUS_CODE.MISSING]: 'schedule',
[STATUS_CODE.APPROVED]: 'check',
[STATUS_CODE.AUTO_APPROVED]: 'published_with_changes',
};
const FILE_STATUS = {
[FileStatus.NEEDS_APPROVAL]: STATUS_CODE.MISSING,
[FileStatus.APPROVED]: STATUS_CODE.APPROVED,
[FileStatus.AUTO_APPROVED]: STATUS_CODE.AUTO_APPROVED,
};
const HOVER_HEADING = {
[STATUS_CODE.MISSING]: "Needs Owners' Approval",
[STATUS_CODE.APPROVED]: 'Approved by Owners',
[STATUS_CODE.AUTO_APPROVED]: 'Auto-approved by Owners',
};
const DISPLAY_OWNERS_FOR_FILE_LIMIT = 5;
const LIMITED_FILES_OWNERS_TOOLTIP = `File owners limited to first ${DISPLAY_OWNERS_FOR_FILE_LIMIT} accounts.`;
const common = OwnersMixin(LitElement);
class FilesCommon extends common {
protected override willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
this.hidden = shouldHide(
this.change,
this.patchRange,
this.filesOwners,
this.user?.role
);
}
protected static commonStyles(): CSSResult[] {
return [window?.Gerrit?.styles.font as CSSResult];
}
}
export const FILES_OWNERS_COLUMN_HEADER = 'files-owners-column-header';
@customElement(FILES_OWNERS_COLUMN_HEADER)
export class FilesColumnHeader extends FilesCommon {
static override get styles() {
return [
...FilesCommon.commonStyles(),
css`
:host() {
display: block;
padding-right: var(--spacing-m);
width: 4em;
}
:host[hidden] {
display: none;
}
`,
];
}
override render() {
if (this.hidden) return nothing;
return html`<div>Status</div>`;
}
}
/**
* It has to be part of this file as components defined in dedicated files are not visible
*/
@customElement('gr-owner')
export class GrOwner extends LitElement {
@property({type: Object})
owner?: AccountInfo;
@property({type: Object})
groupOwner?: GroupInfo;
@property({type: Object})
approval?: ApprovalInfo;
@property({type: Object})
info?: LabelInfo;
@property({type: String})
email?: EmailAddress;
static override get styles() {
return [
css`
.container {
display: flex;
}
gr-vote-chip {
margin-left: 5px;
--gr-vote-chip-width: 14px;
--gr-vote-chip-height: 14px;
}
gr-tooltip-content {
display: inline-block;
}
`,
];
}
override render() {
if (!this.owner && !this.groupOwner) {
return nothing;
}
const isAccountOwner = this.owner !== undefined;
const ownerLabel = isAccountOwner
? this.owner?._account_id
? html`<gr-account-label .account=${this.owner}></gr-account-label>`
: html`<span>${this.owner?.display_name}</span>`
: html`<span>Group: ${this.groupOwner?.name}</span>`;
const voteChip =
isAccountOwner && this.approval
? html` <gr-vote-chip
.vote=${this.approval}
.label=${this.info}
></gr-vote-chip>`
: nothing;
// allow user to copy what is available:
// * email or name (if available) for AccountInfo owner type
// * group name for GroupInfo owner type
const [copyText, copyTooltip] = isAccountOwner
? this.email || this.owner?.display_name
? [
this.email ?? this.owner?.display_name,
this.email ? 'email' : 'name',
]
: [nothing, nothing]
: [this.groupOwner?.name, 'group name'];
const copy = copyText
? html` <gr-copy-clipboard
.text=${copyText}
hasTooltip
hideinput
buttonTitle="Copy owner's ${copyTooltip} to clipboard"
></gr-copy-clipboard>`
: nothing;
return html`
<div class="container">${ownerLabel} ${voteChip} ${copy}</div>
`;
}
override async updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (changedProperties.has('owner')) {
if (this.owner && !this.email) {
const accountLabel = query<GrAccountLabel>(this, 'gr-account-label');
if (!accountLabel) {
return;
}
const updateOwner = await accountLabel
?.getAccountsModel()
?.fillDetails(this.owner);
this.email = updateOwner?.email;
}
}
}
}
export const FILES_OWNERS_COLUMN_CONTENT = 'files-owners-column-content';
@customElement(FILES_OWNERS_COLUMN_CONTENT)
export class FilesColumnContent extends FilesCommon {
@property({type: String})
path?: string;
@property({type: String})
oldPath?: string;
@property({type: String, reflect: true, attribute: 'file-status'})
fileStatus?: string;
private owners?: OwnerOrGroupOwner[];
// taken from Gerrit's common-util.ts
private uniqueId = Math.random().toString(36).substring(2);
static override get styles() {
return [
...FilesCommon.commonStyles(),
css`
:host {
display: flex;
padding-right: var(--spacing-m);
width: 4em;
text-align: center;
}
:host[hidden] {
display: none;
}
gr-icon {
padding: var(--spacing-xs) 0px;
margin-left: 9px;
}
:host([file-status='approved']) gr-icon.status {
color: var(--positive-green-text-color);
}
:host([file-status='autoApproved']) gr-icon.status {
color: var(--positive-green-text-color);
}
:host([file-status='missing']) gr-icon.status {
color: #ffa62f;
}
`,
];
}
override render() {
if (this.hidden || !this.fileStatus) {
return nothing;
}
const icon = STATUS_ICON[this.fileStatus];
return html`
<gr-icon
id="${this.pathId()}"
class="status"
icon=${icon}
aria-hidden="true"
></gr-icon>
${this.renderFileOwners()}
`;
}
private pathId(): string {
return `path-${this.uniqueId}`;
}
private renderFileOwners() {
const owners = this.owners ?? [];
const splicedOwners = owners.splice(0, DISPLAY_OWNERS_FOR_FILE_LIMIT);
const showEllipsis = owners.length > DISPLAY_OWNERS_FOR_FILE_LIMIT;
const heading = HOVER_HEADING[this.fileStatus ?? STATUS_CODE.MISSING];
// inlining <style> here is ugly but an alternative would be to copy the `HovercardMixin` from Gerrit and implement hoover from scratch
return html`<gr-hovercard for="${this.pathId()}">
<style>
#file-owners-hoovercard {
min-width: 256px;
max-width: 256px;
margin: -10px;
padding: var(--spacing-xl) 0 var(--spacing-m) 0;
}
h3 {
font: inherit;
}
.heading-3 {
margin-left: -2px;
font-family: var(--header-font-family);
font-size: var(--font-size-h3);
font-weight: var(--font-weight-h3);
line-height: var(--line-height-h3);
}
.row {
display: flex;
}
div.section {
margin: 0 var(--spacing-xl) var(--spacing-m) var(--spacing-xl);
display: flex;
align-items: center;
}
div.sectionIcon {
flex: 0 0 30px;
}
div.sectionIcon gr-icon {
position: relative;
}
div.sectionContent .row {
margin-left: 2px;
}
div.sectionContent .notLast {
margin-bottom: 2px;
}
div.sectionContent .ellipsis {
margin-left: 5px;
}
</style>
<div id="file-owners-hoovercard">
<div class="section">
<div class="sectionIcon">
<gr-icon class="status" icon="info" aria-hidden="true"></gr-icon>
</div>
<div class="sectionContent">
<h3 class="name heading-3">
<span>${heading}</span>
</h3>
</div>
</div>
<div class="section">
<div class="sectionContent">
${splicedOwners.map((owner, idx) => {
const [approval, info] =
computeApprovalAndInfo(
owner,
this.filesOwners?.owners_labels ?? {},
this.change
) ?? [];
return html`
<div
class="row ${showEllipsis || idx + 1 < splicedOwners.length
? 'notLast'
: ''}"
>
<gr-owner
.owner=${owner.owner}
.groupOwner=${owner.groupOwner}
.approval=${approval}
.info=${info}
.email=${owner.owner ? owner.owner.email : nothing}
></gr-owner>
</div>
`;
})}
${showEllipsis
? html`
<gr-tooltip-content
title=${LIMITED_FILES_OWNERS_TOOLTIP}
aria-label="limited-file-onwers"
aria-description=${LIMITED_FILES_OWNERS_TOOLTIP}
has-tooltip
>
<div class="row ellipsis">...</div>
</gt-tooltip-content>`
: nothing}
</div>
</div>
</div>
</gr-hovercard>`;
}
protected override willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
this.computeFileState();
}
private computeFileState(): void {
const fileOwnership = getFileOwnership(this.path, this.filesOwners);
if (!fileOwnership || fileOwnership.fileStatus === FileStatus.NOT_OWNED) {
this.fileStatus = undefined;
this.owners = undefined;
return;
}
this.fileStatus = FILE_STATUS[fileOwnership.fileStatus];
const accounts = getChangeAccounts(this.change);
this.owners = (fileOwnership.owners ?? []).map(owner => {
if (isOwner(owner)) {
return {
owner:
accounts.byAccountId.get(owner.id) ??
({_account_id: owner.id} as unknown as AccountInfo),
} as unknown as OwnerOrGroupOwner;
}
const groupPrefix = 'group/';
if (owner.name.startsWith(groupPrefix)) {
return {
groupOwner: {
name: owner.name.substring(groupPrefix.length),
} as unknown as GroupInfo,
} as unknown as OwnerOrGroupOwner;
}
// when `owners.expandGroups = false` then neither group nor account
// will be expanded therefore try to match account with change's available
// accounts through email without domain or by full name
// finally construct `AccountInfo` just with a `name` property
const accountOwner =
accounts.byEmailWithoutDomain.get(owner.name) ??
accounts.byFullName.get(owner.name) ??
({display_name: owner.name} as unknown as AccountInfo);
return {owner: accountOwner} as unknown as OwnerOrGroupOwner;
});
}
}
export function shouldHide(
change?: ChangeInfo,
patchRange?: PatchRange,
filesOwners?: FilesOwners,
userRole?: UserRole
): boolean {
// don't show owners when no change or change is merged
if (change === undefined || patchRange === undefined) {
return true;
}
if (
change.status === ChangeStatus.ABANDONED ||
change.status === ChangeStatus.MERGED
) {
return true;
}
// 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 (patchRange?.patchNum === undefined) return false;
// only show if its latest patchset
const latestPatchset = change.revisions![change.current_revision!];
if (`${patchRange.patchNum}` !== `${latestPatchset._number}`) {
return true;
}
// show owners when they apply to the change and for logged in user
if (
hasOwnersSubmitRequirement(change) &&
filesOwners &&
(filesOwners.files ||
filesOwners.files_approved ||
filesOwners.files_auto_approved)
) {
return !userRole || userRole === UserRole.ANONYMOUS;
}
return true;
}
export function getFileOwnership(
path?: string,
filesOwners?: FilesOwners
): FileOwnership | undefined {
if (path === undefined || filesOwners === undefined) {
return undefined;
}
const fileOwners = (filesOwners.files ?? {})[path];
const fileApprovers = (filesOwners.files_approved ?? {})[path];
const fileAutoApprovers = (filesOwners.files_auto_approved ?? {})[path];
if (fileAutoApprovers) {
return {
fileStatus: FileStatus.AUTO_APPROVED,
owners: fileAutoApprovers,
};
} else if (fileApprovers) {
return {
fileStatus: FileStatus.APPROVED,
owners: fileApprovers,
};
} else if (fileOwners) {
return {
fileStatus: FileStatus.NEEDS_APPROVAL,
owners: fileOwners,
};
} else {
return {fileStatus: FileStatus.NOT_OWNED};
}
}
export function computeApprovalAndInfo(
fileOwner: OwnerOrGroupOwner,
labels: OwnersLabels,
change?: ChangeInfo
): [ApprovalInfo, LabelInfo] | undefined {
if (!change?.labels || !fileOwner?.owner) {
return;
}
const accountId = fileOwner.owner._account_id;
const ownersLabel = labels[`${accountId}`];
if (!ownersLabel) {
return;
}
for (const label of Object.keys(ownersLabel)) {
const info = change.labels[label];
if (!info || !isDetailedLabelInfo(info)) {
return;
}
const vote = ownersLabel[label];
if ((info.default_value && info.default_value === vote) || vote === 0) {
// ignore default value
return;
}
const approval = info.all?.filter(x => x._account_id === accountId)[0];
return approval ? [approval, info] : undefined;
}
return;
}
export interface ChangeAccounts {
byAccountId: Map<number, AccountInfo>;
byEmailWithoutDomain: Map<string, AccountInfo>;
byFullName: Map<string, AccountInfo>;
}
export function getChangeAccounts(change?: ChangeInfo): ChangeAccounts {
const accounts = {
byAccountId: new Map(),
byEmailWithoutDomain: new Map(),
byFullName: new Map(),
} as unknown as ChangeAccounts;
if (!change) {
return accounts;
}
[
change.owner,
...(change.submitter ? [change.submitter] : []),
...(change.reviewers[ReviewerState.REVIEWER] ?? []),
...(change.reviewers[ReviewerState.CC] ?? []),
].forEach(account => {
if (account._account_id) {
accounts.byAccountId.set(account._account_id, account);
}
if (account.email && account.email.indexOf('@') > 0) {
accounts.byEmailWithoutDomain.set(
account.email.substring(
0,
account.email.indexOf('@')
) as unknown as string,
account
);
}
if (account.name) {
accounts.byFullName.set(account.name, account);
}
});
return accounts;
}