blob: 9b0cb860431bd51bc9977b753b9a8831955b4bdd [file] [log] [blame]
Chris Poucete5cc37b2022-08-23 16:27:36 +02001/**
2 * @license
3 * Copyright (C) 2020 The Android Open Source Project
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 * http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18import {
Chris Poucet985e27a2022-11-28 14:08:10 +010019 AccountInfo,
Chris Poucete5cc37b2022-08-23 16:27:36 +020020 BasePatchSetNum,
21 RevisionPatchSetNum,
22} from '@gerritcodereview/typescript-api/rest-api';
23import {css, html, LitElement, nothing, PropertyValues} from 'lit';
24import {customElement, property} from 'lit/decorators';
25import {OwnerStatus} from './code-owners-api';
Chris Poucet010ca992023-10-12 10:10:20 +020026import {FileStatus, PluginState} from './code-owners-model';
Chris Poucete5cc37b2022-08-23 16:27:36 +020027import {CodeOwnersModelMixin} from './code-owners-model-mixin';
28
29// TODO: Extend the API for plugins.
30export interface PatchRange {
31 patchNum: RevisionPatchSetNum;
32 basePatchNum: BasePatchSetNum;
33}
34
Kamil Musin8a9288f2023-08-24 14:15:29 +020035const ACCOUNT_TEMPLATE_REGEX = '<GERRIT_ACCOUNT_(\\d+)>';
36
Chris Poucete5cc37b2022-08-23 16:27:36 +020037const MAGIC_FILES = ['/COMMIT_MSG', '/MERGE_LIST', '/PATCHSET_LEVEL'];
38const STATUS_CODE = {
Chris Poucet1565aa62022-11-21 11:46:47 +010039 NO_STATUS: 'no-status',
Chris Poucete5cc37b2022-08-23 16:27:36 +020040 PENDING: 'pending',
41 PENDING_OLD_PATH: 'pending-old-path',
42 MISSING: 'missing',
43 MISSING_OLD_PATH: 'missing-old-path',
44 APPROVED: 'approved',
Chris Poucete5cc37b2022-08-23 16:27:36 +020045};
46
47const STATUS_PRIORITY_ORDER = [
Chris Poucet1565aa62022-11-21 11:46:47 +010048 STATUS_CODE.NO_STATUS,
Chris Poucete5cc37b2022-08-23 16:27:36 +020049 STATUS_CODE.MISSING,
50 STATUS_CODE.PENDING,
51 STATUS_CODE.MISSING_OLD_PATH,
52 STATUS_CODE.PENDING_OLD_PATH,
53 STATUS_CODE.APPROVED,
54];
55
56const STATUS_ICON = {
57 [STATUS_CODE.PENDING]: 'schedule',
58 [STATUS_CODE.MISSING]: 'close',
59 [STATUS_CODE.PENDING_OLD_PATH]: 'schedule',
Chris Poucetf08b0a42022-09-09 17:28:05 +020060 [STATUS_CODE.MISSING_OLD_PATH]: 'close',
Chris Poucete5cc37b2022-08-23 16:27:36 +020061 [STATUS_CODE.APPROVED]: 'check',
Chris Poucet1565aa62022-11-21 11:46:47 +010062 [STATUS_CODE.NO_STATUS]: 'check_circle',
Chris Poucete5cc37b2022-08-23 16:27:36 +020063};
64
Ben Rohlfs0bb3ca52022-10-28 22:01:59 +020065const STATUS_SUMMARY = {
66 [STATUS_CODE.PENDING]: 'Pending',
67 [STATUS_CODE.MISSING]: 'Missing',
68 [STATUS_CODE.PENDING_OLD_PATH]: 'Pending Old Path',
69 [STATUS_CODE.MISSING_OLD_PATH]: 'Missing Old Path',
70 [STATUS_CODE.APPROVED]: 'Approved',
Chris Poucet1565aa62022-11-21 11:46:47 +010071 [STATUS_CODE.NO_STATUS]: 'Does not need approval',
Ben Rohlfs0bb3ca52022-10-28 22:01:59 +020072};
73
Chris Poucete5cc37b2022-08-23 16:27:36 +020074const STATUS_TOOLTIP = {
75 [STATUS_CODE.PENDING]: 'Pending code owner approval',
76 [STATUS_CODE.MISSING]: 'Missing code owner approval',
77 [STATUS_CODE.PENDING_OLD_PATH]:
78 'Pending code owner approval on pre-renamed file',
79 [STATUS_CODE.MISSING_OLD_PATH]:
80 'Missing code owner approval on pre-renamed file',
81 [STATUS_CODE.APPROVED]: 'Approved by code owner',
Chris Poucet1565aa62022-11-21 11:46:47 +010082 [STATUS_CODE.NO_STATUS]: 'Does not need approval',
Chris Poucete5cc37b2022-08-23 16:27:36 +020083};
84
Chris Poucetf08b0a42022-09-09 17:28:05 +020085export function hasPath(ownedPaths: Set<string>, path: string | undefined) {
86 if (!path) return false;
Chris Poucet985e27a2022-11-28 14:08:10 +010087 if (path.charAt(0) !== '/') path = '/' + path;
88 return ownedPaths.has(path);
89}
90
91export function getOwners(
92 owners: Map<string, Array<AccountInfo>>,
93 path: string | undefined
94): Array<AccountInfo> {
95 if (!path) return [];
96 if (path.charAt(0) !== '/') path = '/' + path;
97 return owners.get(path) ?? [];
Chris Poucetf08b0a42022-09-09 17:28:05 +020098}
99
Chris Poucet75798462022-12-12 11:43:38 +0100100export function uniqueAccountId(
101 account: AccountInfo,
102 index: number,
103 accountArray: AccountInfo[]
104) {
105 return (
106 index ===
107 accountArray.findIndex(other => account._account_id === other._account_id)
108 );
109}
110
Chris Poucete5cc37b2022-08-23 16:27:36 +0200111const base = CodeOwnersModelMixin(LitElement);
112
113class BaseEl extends base {
114 @property({type: Object})
115 patchRange?: PatchRange;
116
Chris Poucet25a6e412022-12-12 11:15:44 +0100117 protected override willUpdate(changedProperties: PropertyValues): void {
118 super.willUpdate(changedProperties);
119 this.hidden = this.computeHidden();
120 }
121
Chris Poucete5cc37b2022-08-23 16:27:36 +0200122 computeHidden() {
123 const newerPatchsetUploaded = this.status?.newerPatchsetUploaded;
124 if (
125 this.change === undefined ||
126 this.patchRange === undefined ||
127 newerPatchsetUploaded === undefined
128 ) {
129 return true;
130 }
Chris Poucet010ca992023-10-12 10:10:20 +0200131 if (this.pluginStatus?.state !== PluginState.Enabled) return true;
Chris Poucet25a6e412022-12-12 11:15:44 +0100132 if (this.change.status === 'MERGED') return true;
Chris Poucete5cc37b2022-08-23 16:27:36 +0200133 // if code-owners is not a submit requirement, don't show status column
134 if (
135 this.change.requirements &&
136 !this.change.requirements.find(r => r.type === 'code-owners')
137 ) {
Chris Poucet1565aa62022-11-21 11:46:47 +0100138 return false;
Chris Poucete5cc37b2022-08-23 16:27:36 +0200139 }
140
141 if (newerPatchsetUploaded) return true;
142
143 const latestPatchset =
144 this.change.revisions![this.change.current_revision!];
145 // Note: in some special cases, patchNum is undefined on latest patchset
146 // like after publishing the edit, still show for them
147 // TODO: this should be fixed in Gerrit
148 if (this.patchRange?.patchNum === undefined) return false;
149 // only show if its latest patchset
150 if (`${this.patchRange.patchNum}` !== `${latestPatchset._number}`)
151 return true;
152 return false;
153 }
154}
155
156export const OWNERS_STATUS_COLUMN_HEADER = 'owner-status-column-header';
157/**
158 * Column header element for owner status.
159 */
160@customElement(OWNERS_STATUS_COLUMN_HEADER)
161export class OwnerStatusColumnHeader extends BaseEl {
162 static override get styles() {
163 return [
164 css`
165 :host() {
166 display: block;
167 padding-right: var(--spacing-m);
Chris Poucet985e27a2022-11-28 14:08:10 +0100168 width: 5em;
Chris Poucete5cc37b2022-08-23 16:27:36 +0200169 }
Chris Poucet25a6e412022-12-12 11:15:44 +0100170 :host[hidden] {
171 display: none;
172 }
Chris Poucete5cc37b2022-08-23 16:27:36 +0200173 `,
174 ];
175 }
176
177 override render() {
178 if (this.computeHidden()) return nothing;
Chris Poucet2b4691e2022-11-25 14:43:10 +0100179 return html`<div>Owners</div>`;
Chris Poucete5cc37b2022-08-23 16:27:36 +0200180 }
181}
182
183export const OWNER_STATUS_COLUMN_CONTENT = 'owner-status-column-content';
184/**
185 * Row content element for owner status.
186 */
187@customElement(OWNER_STATUS_COLUMN_CONTENT)
188export class OwnerStatusColumnContent extends BaseEl {
189 @property({type: String})
190 path?: string;
191
192 @property({type: String})
193 oldPath?: string;
194
195 @property({type: Array})
196 cleanlyMergedPaths?: Array<string>;
197
198 @property({type: Array})
199 cleanlyMergedOldPaths?: Array<string>;
200
201 @property({type: String, reflect: true, attribute: 'owner-status'})
202 ownerStatus?: string;
203
Kamil Musin8a9288f2023-08-24 14:15:29 +0200204 @property({type: Array})
205 ownerReasons?: Array<string>;
206
Chris Poucete5cc37b2022-08-23 16:27:36 +0200207 static override get styles() {
208 return [
209 css`
210 :host {
Chris Poucetf08b0a42022-09-09 17:28:05 +0200211 display: flex;
Chris Poucete5cc37b2022-08-23 16:27:36 +0200212 padding-right: var(--spacing-m);
Chris Poucet985e27a2022-11-28 14:08:10 +0100213 width: 5em;
Chris Poucete5cc37b2022-08-23 16:27:36 +0200214 text-align: center;
215 }
Chris Poucet25a6e412022-12-12 11:15:44 +0100216 :host[hidden] {
217 display: none;
218 }
Chris Poucet013b0f02022-09-12 09:17:28 +0200219 gr-icon {
Chris Poucete5cc37b2022-08-23 16:27:36 +0200220 padding: var(--spacing-xs) 0px;
221 }
Chris Poucetf08b0a42022-09-09 17:28:05 +0200222 :host([owner-status='approved']) gr-icon.status {
Chris Poucete5cc37b2022-08-23 16:27:36 +0200223 color: var(--positive-green-text-color);
224 }
Chris Poucetf08b0a42022-09-09 17:28:05 +0200225 :host([owner-status='pending']) gr-icon.status {
Chris Poucete5cc37b2022-08-23 16:27:36 +0200226 color: #ffa62f;
227 }
Chris Poucet1565aa62022-11-21 11:46:47 +0100228 :host([owner-status='missing']) gr-icon.status {
Chris Poucete5cc37b2022-08-23 16:27:36 +0200229 color: var(--negative-red-text-color);
230 }
Chris Poucet985e27a2022-11-28 14:08:10 +0100231 gr-avatar-stack {
232 padding: var(--spacing-xs) 0px;
233 display: flex;
234 --avatar-size: 20px;
235 }
236 .ellipsis {
237 /* These are required to get the ... to line up with the bottom of
238 the avatar icons. */
239 margin-bottom: -2px;
240 display: flex;
241 align-items: flex-end;
242 }
243 .error {
244 color: var(--negative-red-text-color);
245 }
Chris Poucet483a0c02022-12-06 13:53:02 +0100246 .fallback-icon {
247 /* Undo the padding for the gr-avatar-stack in case of fallback */
248 padding: calc(-1 * var(--spacing-xs)) 0px;
249 }
Chris Poucete5cc37b2022-08-23 16:27:36 +0200250 `,
251 ];
252 }
253
254 protected override willUpdate(changedProperties: PropertyValues): void {
255 super.willUpdate(changedProperties);
256 this.computeStatus();
257 }
258
Kamil Musin8a9288f2023-08-24 14:15:29 +0200259 private renderReason(reason: string): string {
260 let reasonWithAccounts = reason.replace(
261 new RegExp(ACCOUNT_TEMPLATE_REGEX, 'g'),
262 (_accountIdTemplate, accountId) => {
263 const parsedAccountId = Number(accountId);
264 const accountInText = (this.status?.accounts || {})[parsedAccountId];
265 if (!accountInText) {
266 return `Gerrit Account ${parsedAccountId}`;
267 }
268 return accountInText.display_name ?? accountInText.name ?? '';
269 }
270 );
271 return (
272 reasonWithAccounts.charAt(0).toUpperCase() + reasonWithAccounts.slice(1)
273 );
274 }
275
Chris Poucete5cc37b2022-08-23 16:27:36 +0200276 override render() {
Chris Poucet985e27a2022-11-28 14:08:10 +0100277 if (
278 this.computeHidden() ||
279 this.status === undefined ||
Kamil Musin8a9288f2023-08-24 14:15:29 +0200280 !this.path ||
Chris Poucet43aacf12023-01-05 10:43:25 +0100281 MAGIC_FILES.includes(this.path)
Chris Poucet985e27a2022-11-28 14:08:10 +0100282 )
283 return nothing;
Chris Poucetf08b0a42022-09-09 17:28:05 +0200284 return html`${this.renderStatus()}${this.renderOwnership()}`;
285 }
Chris Poucete5cc37b2022-08-23 16:27:36 +0200286
Chris Poucetf08b0a42022-09-09 17:28:05 +0200287 private renderStatus() {
Kamil Musin8a9288f2023-08-24 14:15:29 +0200288 let info = STATUS_TOOLTIP[this.ownerStatus!];
289 if (this.ownerReasons) {
290 info = this.ownerReasons.map(r => this.renderReason(r)).join('\n');
291 }
Ben Rohlfs0bb3ca52022-10-28 22:01:59 +0200292 const summary = STATUS_SUMMARY[this.ownerStatus!];
293 const icon = STATUS_ICON[this.ownerStatus!];
Chris Poucete5cc37b2022-08-23 16:27:36 +0200294 return html`
Ben Rohlfs0bb3ca52022-10-28 22:01:59 +0200295 <gr-tooltip-content
296 title=${info}
297 aria-label=${summary}
298 aria-description=${info}
299 has-tooltip
300 >
301 <gr-icon class="status" icon=${icon} aria-hidden="true"></gr-icon>
Chris Poucete5cc37b2022-08-23 16:27:36 +0200302 </gr-tooltip-content>
303 `;
304 }
305
Chris Poucetf08b0a42022-09-09 17:28:05 +0200306 private renderOwnership() {
Chris Poucet985e27a2022-11-28 14:08:10 +0100307 if (this.isOwned()) {
308 return html`
309 <gr-tooltip-content
310 title="You are in OWNERS for this file"
311 aria-label="owned"
312 aria-description="You are an owner of this file"
313 has-tooltip
314 >
315 <gr-icon filled icon="policy" aria-hidden="true"></gr-icon>
316 </gr-tooltip-content>
317 `;
318 } else if (this.ownedPaths) {
Chris Poucet75798462022-12-12 11:43:38 +0100319 let oldOwners = getOwners(this.ownedPaths.oldPathOwners, this.oldPath);
Chris Poucet985e27a2022-11-28 14:08:10 +0100320 const newOwners = getOwners(this.ownedPaths.newPathOwners, this.path);
Chris Poucet75798462022-12-12 11:43:38 +0100321 if (this.oldPath === undefined || this.oldPath === null) {
322 // In case of a file deletion, the Gerrit FE gives 'path' but not 'oldPath'
323 // but code-owners considers a deleted file an oldpath so check the oldpath owners.
324 oldOwners = getOwners(this.ownedPaths.oldPathOwners, this.path);
325 }
326 const allOwners = oldOwners.concat(newOwners).filter(uniqueAccountId);
Chris Poucet985e27a2022-11-28 14:08:10 +0100327
328 return html` <gr-avatar-stack
329 .accounts=${allOwners.slice(0, 3)}
330 .forceFetch=${true}
Chris Poucet21078b52023-08-18 10:21:32 +0200331 .enableHover=${true}
Chris Poucet985e27a2022-11-28 14:08:10 +0100332 >
333 <gr-tooltip-content
334 slot="fallback"
335 title="No reviewer owns this file"
336 aria-label="missing owner"
337 aria-description="No reviewer owns this file"
338 has-tooltip
339 >
Chris Poucet483a0c02022-12-06 13:53:02 +0100340 <gr-icon icon="help" class="error fallback-icon"></gr-icon>
Chris Poucet985e27a2022-11-28 14:08:10 +0100341 </gr-tooltip-content>
342 </gr-avatar-stack>
343 ${allOwners.length > 3
344 ? html`<div class="ellipsis">…</div>`
345 : nothing}`;
346 }
347 return nothing;
Chris Poucetf08b0a42022-09-09 17:28:05 +0200348 }
349
350 private isOwned() {
351 if (!this.ownedPaths) return false;
352 if (
353 hasPath(this.ownedPaths.newPaths, this.path) ||
Chris Poucet75798462022-12-12 11:43:38 +0100354 hasPath(this.ownedPaths.oldPaths, this.oldPath) ||
355 // In case of deletions, the FE gives a path, but code-owners
356 // computes this as being part of the old path.
357 ((this.oldPath === undefined || this.oldPath === null) &&
358 hasPath(this.ownedPaths.oldPaths, this.path))
Chris Poucetf08b0a42022-09-09 17:28:05 +0200359 )
360 return true;
361 return false;
362 }
363
Chris Poucete5cc37b2022-08-23 16:27:36 +0200364 override loadPropertiesAfterModelChanged() {
365 super.loadPropertiesAfterModelChanged();
366 this.modelLoader?.loadStatus();
Chris Poucetf08b0a42022-09-09 17:28:05 +0200367 this.modelLoader?.loadOwnedPaths();
Chris Poucete5cc37b2022-08-23 16:27:36 +0200368 }
369
370 private computeStatus() {
371 if (
372 this.status === undefined ||
373 (this.cleanlyMergedPaths === undefined &&
374 (this.path === undefined || this.oldPath === undefined))
375 ) {
376 return;
377 }
378
379 const codeOwnerStatusMap = this.status.codeOwnerStatusMap;
380 const paths =
381 this.path === undefined ? this.cleanlyMergedPaths : [this.path];
382 const oldPaths =
383 this.oldPath === undefined ? this.cleanlyMergedOldPaths : [this.oldPath];
384
385 const statuses = (paths ?? [])
386 .filter(path => !MAGIC_FILES.includes(path))
Kamil Musin8a9288f2023-08-24 14:15:29 +0200387 .map(path => ({
Kamil Musin847bee62023-08-28 11:53:28 +0200388 status: this.extractStatus(codeOwnerStatusMap.get(path), false),
389 reasons: codeOwnerStatusMap.get(path)?.reasons,
Kamil Musin8a9288f2023-08-24 14:15:29 +0200390 }));
Chris Poucete5cc37b2022-08-23 16:27:36 +0200391 // oldPath may contain null, so filter that as well.
392 const oldStatuses = (oldPaths ?? [])
393 .filter(path => !MAGIC_FILES.includes(path) && !!path)
Kamil Musin8a9288f2023-08-24 14:15:29 +0200394 .map(path => ({
Kamil Musin847bee62023-08-28 11:53:28 +0200395 status: this.extractStatus(codeOwnerStatusMap.get(path), true),
396 reasons: codeOwnerStatusMap.get(path)?.reasons,
Kamil Musin8a9288f2023-08-24 14:15:29 +0200397 }));
Chris Poucete5cc37b2022-08-23 16:27:36 +0200398 const allStatuses = statuses.concat(oldStatuses);
399 if (allStatuses.length === 0) {
400 return;
401 }
Kamil Musin8a9288f2023-08-24 14:15:29 +0200402 const computedStatus = allStatuses.reduce((a, b) =>
403 STATUS_PRIORITY_ORDER.indexOf(a.status) <
404 STATUS_PRIORITY_ORDER.indexOf(b.status)
Chris Poucete5cc37b2022-08-23 16:27:36 +0200405 ? a
406 : b
407 );
Kamil Musin8a9288f2023-08-24 14:15:29 +0200408 this.ownerStatus = computedStatus.status;
409 this.ownerReasons = computedStatus.reasons;
Chris Poucete5cc37b2022-08-23 16:27:36 +0200410 }
411
Kamil Musin847bee62023-08-28 11:53:28 +0200412 private extractStatus(statusItem: FileStatus | undefined, oldPath: boolean) {
Chris Poucete5cc37b2022-08-23 16:27:36 +0200413 if (statusItem === undefined) {
Chris Poucet1565aa62022-11-21 11:46:47 +0100414 return STATUS_CODE.NO_STATUS;
Chris Poucete5cc37b2022-08-23 16:27:36 +0200415 } else if (statusItem.status === OwnerStatus.INSUFFICIENT_REVIEWERS) {
416 return oldPath ? STATUS_CODE.MISSING_OLD_PATH : STATUS_CODE.MISSING;
417 } else if (statusItem.status === OwnerStatus.PENDING) {
418 return oldPath ? STATUS_CODE.PENDING_OLD_PATH : STATUS_CODE.PENDING;
419 } else {
420 return STATUS_CODE.APPROVED;
421 }
422 }
423}