blob: 6a74dcede5cf4248c0f9fbe74cb95978117ce50a [file] [log] [blame]
/**
* @license
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../styles/shared-styles';
import '../../../styles/gr-font-styles';
import '../../../styles/gr-change-metadata-shared-styles';
import '../../../styles/gr-change-view-integration-shared-styles';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import '../../plugins/gr-endpoint-param/gr-endpoint-param';
import '../../plugins/gr-external-style/gr-external-style';
import '../../shared/gr-account-chip/gr-account-chip';
import '../../shared/gr-date-formatter/gr-date-formatter';
import '../../shared/gr-editable-label/gr-editable-label';
import '../../shared/gr-icon/gr-icon';
import '../../shared/gr-limited-text/gr-limited-text';
import '../../shared/gr-linked-chip/gr-linked-chip';
import '../../shared/gr-tooltip-content/gr-tooltip-content';
import '../gr-submit-requirements/gr-submit-requirements';
import '../gr-commit-info/gr-commit-info';
import '../gr-reviewer-list/gr-reviewer-list';
import {
ChangeStatus,
GpgKeyInfoStatus,
InheritedBooleanInfoConfiguredValue,
SubmitType,
} from '../../../constants/constants';
import {changeIsOpen, isOwner} from '../../../utils/change-util';
import {
AccountDetailInfo,
AccountInfo,
ApprovalInfo,
BranchName,
ChangeInfo,
CommitId,
CommitInfo,
ConfigInfo,
GpgKeyInfo,
Hashtag,
isAccount,
isDetailedLabelInfo,
LabelInfo,
LabelNameToInfoMap,
NumericChangeId,
ParentCommitInfo,
RevisionPatchSetNum,
RepoName,
RevisionInfo,
ServerInfo,
} from '../../../types/common';
import {assertIsDefined, assertNever, unique} from '../../../utils/common-util';
import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
import {GrLinkedChip} from '../../shared/gr-linked-chip/gr-linked-chip';
import {getAppContext} from '../../../services/app-context';
import {
Metadata,
isSectionSet,
DisplayRules,
} from '../../../utils/change-metadata-util';
import {fireAlert, fireEvent, fireReload} from '../../../utils/event-util';
import {
EditRevisionInfo,
isDefined,
ParsedChangeInfo,
} from '../../../types/types';
import {
AutocompleteQuery,
AutocompleteSuggestion,
} from '../../shared/gr-autocomplete/gr-autocomplete';
import {getRevertCreatedChangeIds} from '../../../utils/message-util';
import {Interaction} from '../../../constants/reporting';
import {getApprovalInfo, getCodeReviewLabel} from '../../../utils/label-util';
import {LitElement, css, html, nothing, PropertyValues} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
import {fontStyles} from '../../../styles/gr-font-styles';
import {changeMetadataStyles} from '../../../styles/gr-change-metadata-shared-styles';
import {when} from 'lit/directives/when.js';
import {ifDefined} from 'lit/directives/if-defined.js';
import {createSearchUrl} from '../../../models/views/search';
import {createChangeUrl} from '../../../models/views/change';
import {GeneratedWebLink, getChangeWeblinks} from '../../../utils/weblink-util';
const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
export enum ChangeRole {
OWNER = 'owner',
UPLOADER = 'uploader',
AUTHOR = 'author',
COMMITTER = 'committer',
}
export interface CommitInfoWithRequiredCommit extends CommitInfo {
// gr-change-view always assigns commit to CommitInfo
commit: CommitId;
}
const SubmitTypeLabel = new Map<SubmitType, string>([
[SubmitType.FAST_FORWARD_ONLY, 'Fast Forward Only'],
[SubmitType.MERGE_IF_NECESSARY, 'Merge if Necessary'],
[SubmitType.REBASE_IF_NECESSARY, 'Rebase if Necessary'],
[SubmitType.MERGE_ALWAYS, 'Always Merge'],
[SubmitType.REBASE_ALWAYS, 'Rebase Always'],
[SubmitType.CHERRY_PICK, 'Cherry Pick'],
]);
const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
interface PushCertificateValidationInfo {
class: string;
icon: string;
message: string;
}
@customElement('gr-change-metadata')
export class GrChangeMetadata extends LitElement {
@query('#webLinks') webLinks?: HTMLElement;
@property({type: Object}) change?: ParsedChangeInfo;
@property({type: Object}) revertedChange?: ChangeInfo;
@property({type: Object}) account?: AccountDetailInfo;
@property({type: Object}) revision?: RevisionInfo | EditRevisionInfo;
@property({type: Object}) commitInfo?: CommitInfoWithRequiredCommit;
@property({type: Object}) serverConfig?: ServerInfo;
@property({type: Boolean}) parentIsCurrent?: boolean;
@property({type: Object}) repoConfig?: ConfigInfo;
// private but used in test
@state() mutable = false;
@state() private readonly notCurrentMessage = NOT_CURRENT_MESSAGE;
// private but used in test
@state() topicReadOnly = true;
// private but used in test
@state() hashtagReadOnly = true;
@state() private pushCertificateValidation?: PushCertificateValidationInfo;
// private but used in test
@state() settingTopic = false;
// private but used in test
@state() currentParents: ParentCommitInfo[] = [];
@state() private showAllSections = false;
@state() private queryTopic?: AutocompleteQuery;
@state() private queryHashtag?: AutocompleteQuery;
private restApiService = getAppContext().restApiService;
private readonly reporting = getAppContext().reportingService;
constructor() {
super();
this.queryTopic = (input: string) => this.getTopicSuggestions(input);
this.queryHashtag = (input: string) => this.getHashtagSuggestions(input);
}
static override styles = [
sharedStyles,
fontStyles,
changeMetadataStyles,
css`
:host {
display: table;
}
gr-submit-requirements {
--requirements-horizontal-padding: var(--metadata-horizontal-padding);
}
gr-editable-label {
max-width: 9em;
}
.webLink {
display: block;
}
gr-account-chip[disabled],
gr-linked-chip[disabled] {
opacity: 0;
pointer-events: none;
}
.hashtagChip {
padding-bottom: var(--spacing-s);
}
/* consistent with section .title, .value */
.hashtagChip:not(last-of-type) {
padding-bottom: var(--spacing-s);
}
.hashtagChip:last-of-type {
display: inline;
vertical-align: top;
}
.parentList.merge {
list-style-type: decimal;
padding-left: var(--spacing-l);
}
.parentList gr-commit-info {
display: inline-block;
}
.hideDisplay,
#parentNotCurrentMessage {
display: none;
}
.icon {
margin: -3px 0;
}
.icon.help,
.icon.notTrusted {
color: var(--warning-foreground);
}
.icon.invalid {
color: var(--negative-red-text-color);
}
.icon.trusted {
color: var(--positive-green-text-color);
}
.parentList.notCurrent.nonMerge #parentNotCurrentMessage {
--arrow-color: var(--warning-foreground);
display: inline-block;
}
.oldSeparatedSection {
margin-top: var(--spacing-l);
padding: var(--spacing-m) 0;
}
.separatedSection {
padding: var(--spacing-m) 0;
}
.hashtag gr-linked-chip,
.topic gr-linked-chip {
--linked-chip-text-color: var(--link-color);
}
gr-reviewer-list {
--account-max-length: 100px;
max-width: 285px;
}
.metadata-title {
color: var(--deemphasized-text-color);
padding-left: var(--metadata-horizontal-padding);
}
.metadata-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
/* The goal is to achieve alignment of the owner account chip and the
commit message box. Their top border should be on the same line. */
margin-bottom: var(--spacing-s);
}
.show-all-button gr-icon {
color: inherit;
font-size: 18px;
}
gr-vote-chip {
--gr-vote-chip-width: 14px;
--gr-vote-chip-height: 14px;
}
`,
];
override render() {
if (!this.change) return nothing;
return html`<div>
<div class="metadata-header">
<h3 class="metadata-title heading-3">Change Info</h3>
${this.renderShowAllButton()}
</div>
${this.renderSubmitted()} ${this.renderUpdated()} ${this.renderOwner()}
${this.renderNonOwner(ChangeRole.UPLOADER)}
${this.renderNonOwner(ChangeRole.AUTHOR)}
${this.renderNonOwner(ChangeRole.COMMITTER)} ${this.renderReviewers()}
${this.renderCCs()} ${this.renderProjectBranch()} ${this.renderParent()}
${this.renderMergedAs()} ${this.renderShowReverCreatedAs()}
${this.renderTopic()} ${this.renderCherryPickOf()}
${this.renderStrategy()} ${this.renderHashTags()}
${this.renderSubmitRequirements()} ${this.renderWeblinks()}
<gr-endpoint-decorator name="change-metadata-item">
<gr-endpoint-param
name="labels"
.value=${{...this.change?.labels}}
></gr-endpoint-param>
<gr-endpoint-param
name="change"
.value=${this.change}
></gr-endpoint-param>
<gr-endpoint-param
name="revision"
.value=${this.revision}
></gr-endpoint-param>
</gr-endpoint-decorator>
</div>`;
}
private renderShowAllButton() {
return html`<gr-button
link
class="show-all-button"
@click=${this.onShowAllClick}
>${this.showAllSections ? 'Show less' : 'Show all'}
<gr-icon icon="expand_more" ?hidden=${this.showAllSections}></gr-icon>
<gr-icon icon="expand_less" ?hidden=${!this.showAllSections}></gr-icon>
</gr-button>`;
}
private renderSubmitted() {
if (!this.change!.submitted) return nothing;
return html`<section class=${this.computeDisplayState(Metadata.SUBMITTED)}>
<span class="title">Submitted</span>
<span class="value">
<gr-date-formatter
withTooltip
.dateStr=${this.change!.submitted}
showYesterday
></gr-date-formatter>
</span>
</section> `;
}
private renderUpdated() {
return html`<section class=${this.computeDisplayState(Metadata.UPDATED)}>
<span class="title">
<gr-tooltip-content
has-tooltip
title="Last update of (meta)data for this change."
>
Updated
</gr-tooltip-content>
</span>
<span class="value">
<gr-date-formatter
withTooltip
.dateStr=${this.change!.updated}
showYesterday
></gr-date-formatter>
</span>
</section>`;
}
private renderOwner() {
const change = this.change!;
return html`<section class=${this.computeDisplayState(Metadata.OWNER)}>
<span class="title">
<gr-tooltip-content
has-tooltip
title="This user created or uploaded the first patchset of this change."
>
Owner
</gr-tooltip-content>
</span>
<span class="value">
<gr-account-chip
.account=${change.owner}
.change=${change}
highlightAttention
.vote=${this.computeVote(change.owner)}
.label=${this.computeCodeReviewLabel()}
>
<gr-vote-chip
slot="vote-chip"
.vote=${this.computeVote(change.owner)}
.label=${this.computeCodeReviewLabel()}
circle-shape
></gr-vote-chip>
</gr-account-chip>
${when(
this.pushCertificateValidation,
() => html`<gr-tooltip-content
has-tooltip
title=${this.pushCertificateValidation!.message}
>
<gr-icon
icon=${this.pushCertificateValidation!.icon}
class="icon ${this.pushCertificateValidation!.class}"
></gr-icon>
</gr-tooltip-content>`
)}
</span>
</section>`;
}
renderNonOwner(role: ChangeRole) {
if (!this.getNonOwnerRole(role)) return nothing;
let title = '';
let name = '';
if (role === ChangeRole.UPLOADER) {
title =
"This user uploaded the patchset to Gerrit (typically by running the 'git push' command).";
name = 'Uploader';
} else if (role === ChangeRole.AUTHOR) {
title = 'This user wrote the code change.';
name = 'Author';
} else if (role === ChangeRole.COMMITTER) {
title =
'This user committed the code change to the Git repository (typically to the local Git repo before uploading).';
name = 'Committer';
}
return html`<section>
<span class="title">
<gr-tooltip-content has-tooltip .title=${title}>
${name}
</gr-tooltip-content>
</span>
<span class="value">
<gr-account-chip
.account=${this.getNonOwnerRole(role)}
.change=${this.change}
?highlightAttention=${role === ChangeRole.UPLOADER}
.vote=${this.computeVoteForRole(role)}
.label=${this.computeCodeReviewLabel()}
>
<gr-vote-chip
slot="vote-chip"
.vote=${this.computeVoteForRole(role)}
.label=${this.computeCodeReviewLabel()}
circle-shape
></gr-vote-chip>
</gr-account-chip>
</span>
</section>`;
}
private renderReviewers() {
return html`<section class=${this.computeDisplayState(Metadata.REVIEWERS)}>
<span class="title">Reviewers</span>
<span class="value">
<gr-reviewer-list
.change=${this.change}
?mutable=${this.mutable}
reviewers-only
.account=${this.account}
></gr-reviewer-list>
</span>
</section>`;
}
private renderCCs() {
return html`<section class=${this.computeDisplayState(Metadata.CC)}>
<span class="title">CC</span>
<span class="value">
<gr-reviewer-list
.change=${this.change}
?mutable=${this.mutable}
ccs-only
.account=${this.account}
></gr-reviewer-list>
</span>
</section>`;
}
private renderProjectBranch() {
const change = this.change!;
return when(
this.computeShowRepoBranchTogether(),
() =>
html`<section class=${this.computeDisplayState(Metadata.REPO_BRANCH)}>
<span class="title">
<gr-tooltip-content
has-tooltip
title="Repository and branch that the change will be merged into if submitted."
>
Repo | Branch
</gr-tooltip-content>
</span>
<span class="value">
<a href=${this.computeProjectUrl(change.project)}
>${change.project}</a
>
|
<a href=${this.computeBranchUrl(change.project, change.branch)}
>${change.branch}</a
>
</span>
</section>`,
() => html`<section
class=${this.computeDisplayState(Metadata.REPO_BRANCH)}
>
<span class="title">
<gr-tooltip-content
has-tooltip
title="Repository that the change will be merged into if submitted."
>
Repo
</gr-tooltip-content>
</span>
<span class="value">
<a href=${this.computeProjectUrl(change.project)}>
<gr-limited-text
limit="40"
.text=${change.project}
></gr-limited-text>
</a>
</span>
</section>
<section class=${this.computeDisplayState(Metadata.REPO_BRANCH)}>
<span class="title">
<gr-tooltip-content
has-tooltip
title="Branch that the change will be merged into if submitted."
>
Branch
</gr-tooltip-content>
</span>
<span class="value">
<a href=${this.computeBranchUrl(change.project, change.branch)}>
<gr-limited-text
limit="40"
.text=${change.branch}
></gr-limited-text>
</a>
</span>
</section>`
);
}
private renderParent() {
return html`<section class=${this.computeDisplayState(Metadata.PARENT)}>
<span class="title"
>${this.currentParents.length > 1 ? 'Parents' : 'Parent'}</span
>
<span class="value">
<ol class=${this.computeParentListClass()}>
${this.currentParents.map(
parent => html` <li>
<gr-commit-info .commitInfo=${parent}></gr-commit-info>
<gr-tooltip-content
id="parentNotCurrentMessage"
has-tooltip
show-icon
.title=${this.notCurrentMessage}
></gr-tooltip-content>
</li>`
)}
</ol>
</span>
</section>`;
}
private renderMergedAs() {
const changeMerged = this.change?.status === ChangeStatus.MERGED;
if (!changeMerged) return nothing;
return html`<section class=${this.computeDisplayState(Metadata.MERGED_AS)}>
<span class="title">Merged As</span>
<span class="value">
<gr-commit-info
.commitInfo=${this.computeMergedCommitInfo(
this.change?.current_revision,
this.change?.revisions
)}
></gr-commit-info>
</span>
</section>`;
}
private renderShowReverCreatedAs() {
if (!this.showRevertCreatedAs()) return nothing;
return html`<section
class=${this.computeDisplayState(Metadata.REVERT_CREATED_AS)}
>
<span class="title">${this.getRevertSectionTitle()}</span>
<span class="value">
<gr-commit-info
.commitInfo=${this.computeRevertCommit()}
></gr-commit-info>
</span>
</section>`;
}
private renderTopic() {
const showTopic = this.change?.topic || !this.topicReadOnly;
if (!showTopic) return nothing;
return html`<section
class="topic ${this.computeDisplayState(Metadata.TOPIC, this.account)}"
>
<span class="title">Topic</span>
<span class="value">
${when(
this.showTopicChip(),
() => html` <gr-linked-chip
.text=${this.change?.topic}
limit="40"
href=${createSearchUrl({topic: this.change!.topic!})}
?removable=${!this.topicReadOnly}
@remove=${this.handleTopicRemoved}
></gr-linked-chip>`
)}
${when(
this.showAddTopic(),
() =>
html` <gr-editable-label
class="topicEditableLabel"
labelText="Set topic"
.confirmLabel=${'Set Topic'}
.value=${this.change?.topic}
maxLength="1024"
.placeholder=${this.computeTopicPlaceholder()}
?readOnly=${this.topicReadOnly}
@changed=${this.handleTopicChanged}
showAsEditPencil
autocomplete
.query=${this.queryTopic}
></gr-editable-label>`
)}
</span>
</section>`;
}
private renderCherryPickOf() {
if (!this.showCherryPickOf()) return nothing;
return html` <section
class=${this.computeDisplayState(Metadata.CHERRY_PICK_OF)}
>
<span class="title">Cherry pick of</span>
<span class="value">
<a
href=${this.computeCherryPickOfUrl(
this.change?.cherry_pick_of_change,
this.change?.cherry_pick_of_patch_set,
this.change?.project
)}
>
<gr-limited-text
text="${this.change?.cherry_pick_of_change},${this.change
?.cherry_pick_of_patch_set}"
limit="40"
>
</gr-limited-text>
</a>
</span>
</section>`;
}
private renderStrategy() {
if (!changeIsOpen(this.change)) return nothing;
return html`<section
class="strategy ${this.computeDisplayState(Metadata.STRATEGY)}"
>
<span class="title">Strategy</span>
<span class="value">${this.computeStrategy()}</span>
</section>`;
}
private renderHashTags() {
return html`<section
class="hashtag ${this.computeDisplayState(Metadata.HASHTAGS)}"
>
<span class="title">Hashtags</span>
<span class="value">
${(this.change?.hashtags ?? []).map(
hashtag => html`<gr-linked-chip
class="hashtagChip"
.text=${hashtag}
href=${this.computeHashtagUrl(hashtag)}
?removable=${!this.hashtagReadOnly}
@remove=${this.handleHashtagRemoved}
limit="40"
>
</gr-linked-chip>`
)}
${when(
!this.hashtagReadOnly,
() => html`
<gr-editable-label
uppercase
labelText="Add a hashtag"
.placeholder=${this.computeHashtagPlaceholder()}
.readOnly=${this.hashtagReadOnly}
@changed=${this.handleHashtagChanged}
showAsEditPencil
autocomplete
.query=${this.queryHashtag}
></gr-editable-label>
`
)}
</span>
</section>`;
}
private renderSubmitRequirements() {
return html`<div class="separatedSection">
<gr-submit-requirements
.change=${this.change}
.account=${this.account}
.mutable=${this.mutable}
></gr-submit-requirements>
</div>`;
}
private renderWeblinks() {
const webLinks = this.computeWebLinks();
if (!webLinks.length) return nothing;
return html`<section id="webLinks">
<span class="title">Links</span>
<span class="value">
${webLinks.map(
link => html`<a
href=${ifDefined(link.url)}
class="webLink"
rel="noopener"
target="_blank"
>
${link.name}
</a>`
)}
</span>
</section>`;
}
override willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has('account')) {
this.mutable = this.computeIsMutable();
}
if (changedProperties.has('mutable') || changedProperties.has('change')) {
this.topicReadOnly = this.computeTopicReadOnly();
this.hashtagReadOnly = this.computeHashtagReadOnly();
}
if (changedProperties.has('change')) {
this.settingTopic = false;
}
if (
changedProperties.has('serverConfig') ||
changedProperties.has('change') ||
changedProperties.has('repoConfig')
) {
this.pushCertificateValidation = this.computePushCertificateValidation();
}
if (changedProperties.has('revision') || changedProperties.has('change')) {
this.currentParents = this.computeParents();
}
}
// private but used in test
computeWebLinks(): GeneratedWebLink[] {
return getChangeWeblinks(this.commitInfo?.web_links, this.serverConfig);
}
private computeStrategy() {
if (!this.change?.submit_type) {
return '';
}
return SubmitTypeLabel.get(this.change.submit_type);
}
// private but used in test
computeLabelNames(labels?: LabelNameToInfoMap) {
return labels ? Object.keys(labels).sort() : [];
}
// private but used in test
async handleTopicChanged(e: CustomEvent<string>) {
assertIsDefined(this.change, 'change');
const topic = e.detail.length ? e.detail : undefined;
this.settingTopic = true;
try {
fireAlert(this, 'Saving topic and reloading ...');
await this.restApiService.setChangeTopic(this.change._number, topic);
} finally {
this.settingTopic = false;
}
fireEvent(this, 'hide-alert');
fireReload(this);
}
// private but used in test
showAddTopic() {
const hasTopic = !!this.change?.topic;
return !hasTopic && !this.settingTopic && this.topicReadOnly === false;
}
// private but used in test
showTopicChip() {
const hasTopic = !!this.change?.topic;
return hasTopic && !this.settingTopic;
}
// private but used in test
showCherryPickOf() {
const hasCherryPickOf =
!!this.change?.cherry_pick_of_change &&
!!this.change?.cherry_pick_of_patch_set;
return hasCherryPickOf;
}
// private but used in test
async handleHashtagChanged(e: CustomEvent<string>) {
assertIsDefined(this.change, 'change');
const newHashtag = e.detail.length ? e.detail : undefined;
if (!newHashtag?.length) return;
fireAlert(this, 'Saving hashtag and reloading ...');
await this.restApiService.setChangeHashtag(this.change._number, {
add: [newHashtag as Hashtag],
});
fireEvent(this, 'hide-alert');
fireReload(this);
}
// private but used in test
computeTopicReadOnly() {
return !this.mutable || !this.change?.actions?.topic?.enabled;
}
// private but used in test
computeHashtagReadOnly() {
return !this.mutable || !this.change?.actions?.hashtags?.enabled;
}
private computeTopicPlaceholder() {
// Action items in Material Design are uppercase -- placeholder label text
// is sentence case.
return this.topicReadOnly ? 'No topic' : 'Set Topic';
}
private computeHashtagPlaceholder() {
return this.hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
}
/**
* private but used in test
*
* @return object representing data for the push validation.
*/
computePushCertificateValidation():
| PushCertificateValidationInfo
| undefined {
if (!this.change || !this.serverConfig?.receive?.enable_signed_push)
return undefined;
if (!this.isEnabledSignedPushOnRepo()) {
return undefined;
}
const rev = this.change.revisions[this.change.current_revision];
if (!rev.push_certificate?.key) {
return {
class: 'help filled',
icon: 'help',
message: 'This patch set was created without a push certificate',
};
}
const key = rev.push_certificate.key;
switch (key.status) {
case GpgKeyInfoStatus.BAD:
return {
class: 'invalid',
icon: 'close',
message: this.problems('Push certificate is invalid', key),
};
case GpgKeyInfoStatus.OK:
return {
class: 'notTrusted filled',
icon: 'info',
message: this.problems(
'Push certificate is valid, but key is not trusted',
key
),
};
case GpgKeyInfoStatus.TRUSTED:
return {
class: 'trusted',
icon: 'check',
message: this.problems(
'Push certificate is valid and key is trusted',
key
),
};
case undefined:
// TODO(TS): Process it correctly
throw new Error('deleted certificate');
default:
assertNever(key.status, `unknown certificate status: ${key.status}`);
}
}
// private but used in test
isEnabledSignedPushOnRepo() {
if (!this.repoConfig?.enable_signed_push) return false;
const enableSignedPush = this.repoConfig.enable_signed_push;
return (
(enableSignedPush.configured_value ===
InheritedBooleanInfoConfiguredValue.INHERIT &&
enableSignedPush.inherited_value) ||
enableSignedPush.configured_value ===
InheritedBooleanInfoConfiguredValue.TRUE
);
}
private problems(msg: string, key: GpgKeyInfo) {
if (!key?.problems || key.problems.length === 0) {
return msg;
}
return [msg + ':'].concat(key.problems).join('\n');
}
private computeShowRepoBranchTogether() {
const {project, branch} = this.change!;
return !!project && !!branch && project.length + branch.length < 40;
}
private computeProjectUrl(project?: RepoName) {
if (!project) return '';
return createSearchUrl({repo: project});
}
private computeBranchUrl(project?: RepoName, branch?: BranchName) {
if (!project || !branch || !this.change || !this.change.status) return '';
return createSearchUrl({
branch,
repo: project,
statuses:
this.change.status === ChangeStatus.NEW
? ['open']
: [this.change.status.toLowerCase()],
});
}
private computeCherryPickOfUrl(
change?: NumericChangeId,
patchset?: RevisionPatchSetNum,
project?: RepoName
) {
if (!change || !project) {
return '';
}
return createChangeUrl({
changeNum: change,
repo: project,
usp: 'metadata',
patchNum: patchset,
});
}
private computeHashtagUrl(hashtag: Hashtag) {
return createSearchUrl({hashtag, statuses: ['open', 'merged']});
}
private async handleTopicRemoved(e: CustomEvent) {
assertIsDefined(this.change, 'change');
const target = e.composedPath()[0] as GrLinkedChip;
target.disabled = true;
try {
fireAlert(this, 'Removing topic and reloading ...');
await this.restApiService.setChangeTopic(this.change._number);
} finally {
target.disabled = false;
}
fireEvent(this, 'hide-alert');
fireReload(this);
}
// private but used in test
async handleHashtagRemoved(e: CustomEvent) {
e.preventDefault();
assertIsDefined(this.change, 'change');
const target = e.target as GrLinkedChip;
target.disabled = true;
try {
fireAlert(this, 'Removing hashtag and reloading ...');
await this.restApiService.setChangeHashtag(this.change._number, {
remove: [target.text as Hashtag],
});
} finally {
target.disabled = false;
}
fireEvent(this, 'hide-alert');
fireReload(this);
}
private computeDisplayState(section: Metadata, account?: AccountDetailInfo) {
// special case for Topic - show always for owners, others when set
if (section === Metadata.TOPIC) {
if (
this.showAllSections ||
isOwner(this.change, account) ||
isSectionSet(section, this.change)
) {
return '';
} else {
return 'hideDisplay';
}
}
if (
this.showAllSections ||
DisplayRules.ALWAYS_SHOW.includes(section) ||
(DisplayRules.SHOW_IF_SET.includes(section) &&
isSectionSet(section, this.change))
) {
return '';
}
return 'hideDisplay';
}
// private but used in test
computeMergedCommitInfo(
currentrevision?: CommitId,
revisions?: {[revisionId: string]: RevisionInfo | EditRevisionInfo}
): CommitInfo | undefined {
if (!currentrevision || !revisions) return;
const rev = revisions[currentrevision];
if (!rev || !rev.commit) return;
// CommitInfo.commit is optional. Set commit in all cases to avoid error
// in <gr-commit-info>. @see Issue 5337
if (!rev.commit.commit) {
rev.commit.commit = currentrevision;
}
return rev.commit;
}
private getRevertSectionTitle() {
return this.revertedChange?.status === ChangeStatus.MERGED
? 'Revert Submitted As'
: 'Revert Created As';
}
// private but used in test
showRevertCreatedAs() {
if (!this.change?.messages) return false;
return getRevertCreatedChangeIds(this.change.messages).length > 0;
}
// private but used in test
computeRevertCommit(): CommitInfo | undefined {
const {revertedChange, change} = this;
if (revertedChange?.current_revision && revertedChange?.revisions) {
// TODO(TS): Fix typing
return {
commit: this.computeMergedCommitInfo(
revertedChange.current_revision,
revertedChange.revisions
),
} as CommitInfo;
}
if (!change?.messages) return undefined;
// TODO(TS): Fix typing
return {
commit: getRevertCreatedChangeIds(change.messages)?.[0],
} as unknown as CommitInfo;
}
// private but used in test
onShowAllClick() {
this.showAllSections = !this.showAllSections;
this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
sectionName: 'metadata',
toState: this.showAllSections ? 'Show all' : 'Show less',
});
}
/**
* Get the user with the specified role on the change. Returns undefined if the
* user with that role is the same as the owner.
* private but used in test
*/
getNonOwnerRole(role: ChangeRole) {
if (!this.change?.revisions?.[this.change.current_revision])
return undefined;
const rev = this.change.revisions[this.change.current_revision];
if (!rev) return undefined;
if (
role === ChangeRole.UPLOADER &&
rev.uploader &&
this.change.owner._account_id !== rev.uploader._account_id
) {
return rev.uploader;
}
if (
role === ChangeRole.AUTHOR &&
rev.commit?.author &&
this.change.owner.email !== rev.commit.author.email
) {
return rev.commit.author;
}
if (
role === ChangeRole.COMMITTER &&
rev.commit?.committer &&
this.change.owner.email !== rev.commit.committer.email &&
!(
rev.uploader?.email && rev.uploader.email === rev.commit.committer.email
)
) {
return rev.commit.committer;
}
return undefined;
}
// private but used in test
computeParents(): ParentCommitInfo[] {
const {change, revision} = this;
if (!revision?.commit) {
if (!change?.current_revision) return [];
const newRevision = change.revisions[change.current_revision];
return newRevision?.commit?.parents ?? [];
}
return revision?.commit?.parents ?? [];
}
// private but used in test
computeParentListClass() {
return [
'parentList',
this.currentParents.length > 1 ? 'merge' : 'nonMerge',
this.parentIsCurrent ? 'current' : 'notCurrent',
].join(' ');
}
private computeIsMutable() {
return !!this.account && !!Object.keys(this.account).length;
}
editTopic() {
if (this.topicReadOnly || !this.change || this.change.topic) {
return;
}
const topicEditableLabel = this.shadowRoot!.querySelector<GrEditableLabel>(
'.topicEditableLabel'
);
if (topicEditableLabel) {
topicEditableLabel.open();
}
}
private getTopicSuggestions(
input: string
): Promise<AutocompleteSuggestion[]> {
return this.restApiService
.getChangesWithSimilarTopic(input)
.then(response =>
(response ?? [])
.map(change => change.topic)
.filter(isDefined)
.filter(unique)
.map(topic => {
return {name: topic, value: topic};
})
);
}
private getHashtagSuggestions(
input: string
): Promise<AutocompleteSuggestion[]> {
return this.restApiService
.getChangesWithSimilarHashtag(input)
.then(response =>
(response ?? [])
.flatMap(change => change.hashtags ?? [])
.filter(isDefined)
.filter(unique)
.map(hashtag => {
return {name: hashtag, value: hashtag};
})
);
}
private computeVoteForRole(role: ChangeRole) {
const reviewer = this.getNonOwnerRole(role);
if (reviewer && isAccount(reviewer)) {
return this.computeVote(reviewer);
} else {
return;
}
}
private computeVote(reviewer: AccountInfo): ApprovalInfo | undefined {
const codeReviewLabel = this.computeCodeReviewLabel();
if (!codeReviewLabel || !isDetailedLabelInfo(codeReviewLabel)) return;
return getApprovalInfo(codeReviewLabel, reviewer);
}
private computeCodeReviewLabel(): LabelInfo | undefined {
if (!this.change?.labels) return;
return getCodeReviewLabel(this.change.labels);
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-change-metadata': GrChangeMetadata;
}
}