blob: 9d28ab28f7e89d956dc6b35071bba233b90b4c93 [file] [log] [blame] [edit]
/**
* @license
* Copyright (C) 2025 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 {ChangeInfo, NumericChangeId} from '@gerritcodereview/typescript-api/rest-api';
import {LitElement, html, css, nothing} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
declare global {
interface HTMLElementTagNameMap {
'gr-zuul': GrZuul;
}
}
interface CrdInfo {
depends_on_found?: ChangeInfo[];
depends_on_missing?: string[];
needed_by?: ChangeInfo[];
cycle?: boolean;
}
// Partial copy of https://github.com/GerritCodeReview/gerrit/blob/b42341c5cd9b1f1535df30b16f180a90617fd067/polygerrit-ui/app/types/common.ts#L1377
interface RelatedChangeAndCommitInfo {
_change_number?: NumericChangeId;
_revision_number?: number;
_current_revision_number?: number;
status?: string;
submittable?: boolean;
}
@customElement('gr-zuul')
export class GrZuul extends LitElement {
@property({type: Object}) change?: ChangeInfo;
@state() private _crd: CrdInfo = {};
@state() private _crdLoaded = false;
static override get styles() {
return [
css`
section.related-changes-section {
margin-bottom: 1.4em;
display: block;
}
a {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.changeContainer {
display: flex;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.changeContainer.thisChange:before {
content: '➔';
width: 1.2em;
}
h4,
section div {
display: flex;
}
h4:before,
section div:before {
content: ' ';
flex-shrink: 0;
width: 1.2em;
}
.status {
color: var(--deemphasized-text-color);
font-weight: var(--font-weight-bold);
margin-left: var(--spacing-xs);
}
.dependencyCycleDetected,
.missingFromThisServer {
color: #d17171;
}
.hidden {
display: none;
}
`,
];
}
override render() {
if (!this._crdLoaded) return nothing;
return html`
${this._isDependsOnSectionVisible()
? html`
<section class="related-changes-section">
<h4>Depends on</h4>
${this._crd.depends_on_found?.map(
item => html`
<div class="changeContainer zuulDependencyContainer">
<a
href=${this._computeDependencyUrl(item)}
title="${item.project}: ${item.branch}: ${item.subject}"
>
${item.project}: ${item.branch}: ${item.subject}
</a>
<span class=${this._computeChangeStatusClass(item)}>
(${this._computeChangeStatus(item)})
</span>
${this._crd.cycle
? html`
<span class="status dependencyCycleDetected">
(Dependency cycle detected)
</span>
`
: nothing}
</div>
`
)}
${this._crd.depends_on_missing?.map(
item => html`
<div class="changeContainer zuulDependencyContainer">
<span>${item}</span>
<span class="status missingFromThisServer">
(Missing from this server)
</span>
</div>
`
)}
</section>
`
: nothing}
${this._crd.needed_by?.length
? html`
<section class="related-changes-section">
<h4>Needed by</h4>
${this._crd.needed_by.map(
item => html`
<div class="changeContainer zuulDependencyContainer">
<a
href=${this._computeDependencyUrl(item)}
title="${item.project}: ${item.branch}: ${item.subject}"
>
${item.project}: ${item.branch}: ${item.subject}
</a>
<span class=${this._computeChangeStatusClass(item)}>
(${this._computeChangeStatus(item)})
</span>
${this._crd.cycle
? html`
<span class="status dependencyCycleDetected">
(Dependency cycle detected)
</span>
`
: nothing}
</div>
`
)}
</section>
`
: nothing}
`;
}
override updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('change')) {
void this._onChangeChanged();
}
}
private async _onChangeChanged(): Promise<void> {
this._crdLoaded = false;
this._setHidden(true);
if (!this.change?.id) return;
const url = `/changes/${this.change.id}/revisions/current/crd`;
const plugin = (this as any).plugin;
const crd: CrdInfo = await plugin.restApi().send('GET', url);
this._crd = crd;
this._crdLoaded = true;
const visible = this._isDependsOnSectionVisible() || (crd.needed_by?.length ?? 0) > 0;
this._setHidden(!visible);
}
private _setHidden(hidden: boolean): void {
if (this.hidden !== hidden) {
this.hidden = hidden;
this.dispatchEvent(
new CustomEvent('new-section-loaded', {
composed: true,
bubbles: true,
})
);
}
}
private _computeChangeStatusClass(change: RelatedChangeAndCommitInfo): string {
const classes = ['status'];
if (change._revision_number !== change._current_revision_number) {
classes.push('notCurrent');
} else if (change.submittable) {
classes.push('submittable');
} else if (change.status === 'NEW') {
classes.push('hidden');
}
return classes.join(' ');
}
private _computeChangeStatus(change: RelatedChangeAndCommitInfo): string {
switch (change.status) {
case 'MERGED':
return 'Merged';
case 'ABANDONED':
return 'Abandoned';
default:
if (change._revision_number !== change._current_revision_number) {
return 'Not current';
} else if (change.submittable) {
return 'Submittable';
}
return '';
}
}
private _computeDependencyUrl(changeInfo: ChangeInfo): string {
const base = (window as any).CANONICAL_PATH || '';
return `${base}/q/${changeInfo.change_id}`;
}
private _isDependsOnSectionVisible(): boolean {
const {depends_on_found, depends_on_missing} = this._crd;
return (depends_on_found?.length ?? 0) + (depends_on_missing?.length ?? 0) > 0;
}
}