| /** |
| * @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; |
| } |
| } |