blob: 650fe41569e694c95aff680f4c4684f94ba3e565 [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 '../../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 '../../shared/gr-weblink/gr-weblink';
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,
WebLinkInfo,
} 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,
fire,
fireReload,
fireError,
} 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 {createSearchUrl} from '../../../models/views/search';
import {createChangeUrl} from '../../../models/views/change';
import {getChangeWeblinks} from '../../../utils/weblink-util';
import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
import {subscribe} from '../../lit/subscription-controller';
import {userModelToken} from '../../../models/user/user-model';
import {resolve} from '../../../models/dependency';
import {configModelToken} from '../../../models/config/config-model';
import {changeModelToken} from '../../../models/change/change-model';
import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
import {truncatePath} from '../../../utils/path-list-util';
import {accountEmail, getDisplayName} from '../../../utils/display-name-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;
// TODO: Convert to @state. That requires the change model to keep track of
// current revision actions. Then we can also get rid of the
// `revision-actions-changed` event.
@property({type: Boolean}) parentIsCurrent?: boolean;
@state() change?: ParsedChangeInfo;
@state() revertedChange?: ChangeInfo;
@state() editMode = false;
@state() account?: AccountDetailInfo;
@state() revision?: RevisionInfo | EditRevisionInfo;
@state() serverConfig?: ServerInfo;
@state() repoConfig?: ConfigInfo;
@state() mutable = false;
@state() readonly notCurrentMessage = NOT_CURRENT_MESSAGE;
@state() topicReadOnly = true;
@state() hashtagReadOnly = true;
@state() pushCertificateValidation?: PushCertificateValidationInfo;
@state() settingTopic = false;
@state() currentParents: ParentCommitInfo[] = [];
@state() showAllSections = false;
@state() queryTopic?: AutocompleteQuery;
@state() queryHashtag?: AutocompleteQuery;
private restApiService = getAppContext().restApiService;
private readonly reporting = getAppContext().reportingService;
private readonly getUserModel = resolve(this, userModelToken);
private readonly getConfigModel = resolve(this, configModelToken);
private readonly getChangeModel = resolve(this, changeModelToken);
private readonly getRelatedChangesModel = resolve(
this,
relatedChangesModelToken
);
constructor() {
super();
subscribe(
this,
() => this.getConfigModel().serverConfig$,
serverConfig => (this.serverConfig = serverConfig)
);
subscribe(
this,
() => this.getConfigModel().repoConfig$,
repoConfig => (this.repoConfig = repoConfig)
);
subscribe(
this,
() => this.getUserModel().account$,
account => (this.account = account)
);
subscribe(
this,
() => this.getChangeModel().change$,
change => (this.change = change)
);
subscribe(
this,
() => this.getChangeModel().revision$,
revision => (this.revision = revision)
);
subscribe(
this,
() => this.getRelatedChangesModel().revertingChange$,
revertingChange => (this.revertedChange = revertingChange)
);
subscribe(
this,
() => this.getChangeModel().editMode$,
x => (this.editMode = x)
);
this.queryTopic = (input: string) => this.getTopicSuggestions(input);
this.queryHashtag = (input: string) => this.getHashtagSuggestions(input);
}
static override get styles() {
return [
sharedStyles,
fontStyles,
changeMetadataStyles,
css`
:host {
display: table;
}
gr-submit-requirements {
--requirements-horizontal-padding: var(--metadata-horizontal-padding);
}
gr-editable-label {
max-width: 9em;
}
gr-weblink {
display: block;
}
gr-account-chip {
display: inline;
}
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.renderShowRevertCreatedAs()}
${this.renderTopic()} ${this.renderCherryPickOf()}
${this.renderRevertOf()} ${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>
${when(
this.editMode &&
(role === ChangeRole.AUTHOR || role === ChangeRole.COMMITTER),
() => html`
<gr-editable-label
id="${role}-edit-label"
placeholder="Update ${name}"
@changed=${(e: CustomEvent<string>) =>
this.handleIdentityChanged(e, role)}
showAsEditPencil
autocomplete
.query=${(text: string) => this.getIdentitySuggestions(text)}
></gr-editable-label>
`
)}
</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)}
.title=${change.project}
>
${truncatePath(change.project, 3)}
</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 renderShowRevertCreatedAs() {
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 renderRevertOf() {
if (!this.change?.revert_of) return nothing;
return html` <section class=${this.computeDisplayState(Metadata.REVERT_OF)}>
<span class="title">Revert of</span>
<span class="value">
<a
href=${createChangeUrl({
changeNum: this.change.revert_of,
repo: this.change.project,
usp: 'metadata',
})}
>${this.change.revert_of}</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(info => html`<gr-weblink .info=${info}></gr-weblink>`)}
</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(): WebLinkInfo[] {
return getChangeWeblinks(
this.revision?.commit?.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;
}
fire(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],
});
fire(this, 'hide-alert', {});
fireReload(this);
}
// private but used in test
async handleIdentityChanged(e: CustomEvent<string>, role: ChangeRole) {
assertIsDefined(this.change, 'change');
const input = e.detail.length ? e.detail.trim() : undefined;
if (!input?.length) return;
const reg = /(\w+.*)\s<(\S+@\S+.\S+)>/;
const [, name, email] = input.match(reg) ?? [];
if (!name || !email) {
fireError(
this,
'Invalid input format, valid identity format is "FullName <user@example.com>"'
);
return;
}
fireAlert(this, 'Saving identity and reloading ...');
await this.restApiService.updateIdentityInChangeEdit(
this.change._number,
name,
email,
role.toUpperCase()
);
fire(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(repo?: RepoName, branch?: BranchName) {
if (!repo || !branch || !this.change || !this.change.status) return '';
return createSearchUrl({branch, repo});
}
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: Event) {
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;
}
fire(this, 'hide-alert', {});
fireReload(this);
}
// private but used in test
async handleHashtagRemoved(e: Event) {
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;
}
fire(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.editMode || this.change.owner.email !== rev.commit.author.email)
) {
return rev.commit.author;
}
if (
role === ChangeRole.COMMITTER &&
rev.commit?.committer &&
(this.editMode ||
(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, throwingErrorCallback)
.then(response =>
(response ?? [])
.map(change => change.topic)
.filter(isDefined)
.filter(unique)
.map(topic => {
return {name: topic, value: topic};
})
);
}
private getHashtagSuggestions(
input: string
): Promise<AutocompleteSuggestion[]> {
const inputReg = input.startsWith('^') ? new RegExp(input) : null;
return this.restApiService
.getChangesWithSimilarHashtag(input, throwingErrorCallback)
.then(response =>
(response ?? [])
.flatMap(change => change.hashtags ?? [])
.filter(isDefined)
.filter(unique)
.filter(hashtag =>
inputReg ? inputReg.test(hashtag) : hashtag.includes(input)
)
.map(hashtag => {
return {name: hashtag, value: hashtag};
})
);
}
private async getIdentitySuggestions(
input: string
): Promise<AutocompleteSuggestion[]> {
const suggestions = await this.restApiService.getAccountSuggestions(input);
if (!suggestions) return [];
const identitySuggestions: AutocompleteSuggestion[] = [];
suggestions.forEach(account => {
const name = getDisplayName(this.serverConfig, account);
const emails: string[] = [];
account.email && emails.push(account.email);
account.secondary_emails && emails.push(...account.secondary_emails);
emails.forEach(email => {
const identity = name + ' ' + accountEmail(email);
identitySuggestions.push({name: identity});
});
});
return identitySuggestions;
}
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;
}
}