| /** | |
| * @license | |
| * Copyright (C) 2020 The Android Open Source Project | |
| * | |
| * Licensed under the Apache License, Version 2.0 (the "License"); | |
| * you may not use this file except in compliance with the License. | |
| * You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, software | |
| * distributed under the License is distributed on an "AS IS" BASIS, | |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| * See the License for the specific language governing permissions and | |
| * limitations under the License. | |
| */ | |
| import {BehaviorSubject, Observable} from 'rxjs'; | |
| import { | |
| AccountInfo, | |
| type ChangeInfo, | |
| } from '@gerritcodereview/typescript-api/rest-api'; | |
| import { | |
| ChangeType, | |
| CodeOwnerBranchConfigInfo, | |
| FetchedFile, | |
| FileCodeOwnerStatusInfo, | |
| OwnerStatus, | |
| OwnedPathsInfo, | |
| } from './code-owners-api'; | |
| import {deepEqual} from './deep-util'; | |
| import {select} from './observable-util'; | |
| export enum SuggestionsState { | |
| NotLoaded = 'NotLoaded', | |
| Loaded = 'Loaded', | |
| Loading = 'Loading', | |
| LoadFailed = 'LoadFailed', | |
| } | |
| export enum PluginState { | |
| Enabled = 'Enabled', | |
| Disabled = 'Disabled', | |
| ServerConfigurationError = 'ServerConfigurationError', | |
| Failed = 'Failed', | |
| } | |
| export interface PluginStatus { | |
| state: PluginState; | |
| failedMessage?: string; | |
| } | |
| export function isPluginErrorState(state: PluginState) { | |
| return ( | |
| state === PluginState.ServerConfigurationError || | |
| state === PluginState.Failed | |
| ); | |
| } | |
| export enum SuggestionsType { | |
| BEST_SUGGESTIONS = 'BEST_SUGGESTIONS', | |
| ALL_SUGGESTIONS = 'ALL_SUGGESTIONS', | |
| } | |
| /** | |
| * @enum | |
| */ | |
| export enum UserRole { | |
| ANONYMOUS = 'ANONYMOUS', | |
| AUTHOR = 'AUTHOR', | |
| CHANGE_OWNER = 'CHANGE_OWNER', | |
| REVIEWER = 'REVIEWER', | |
| CC = 'CC', | |
| REMOVED_REVIEWER = 'REMOVED_REVIEWER', | |
| OTHER = 'OTHER', | |
| } | |
| export interface Suggestion { | |
| files?: Array<FetchedFile>; | |
| state: SuggestionsState; | |
| loadProgress?: string; | |
| } | |
| export interface Status { | |
| patchsetNumber: number; | |
| enabled: boolean; | |
| codeOwnerStatusMap: Map<string, FileStatus>; | |
| rawStatuses: Array<FileCodeOwnerStatusInfo>; | |
| newerPatchsetUploaded: boolean; | |
| accounts?: {[account_id: number]: AccountInfo}; | |
| } | |
| export interface FileStatus { | |
| changeType: ChangeType; | |
| status: OwnerStatus; | |
| newPath?: string | null; | |
| oldPath?: string | null; | |
| reasons?: Array<string>; | |
| } | |
| export const BestSuggestionsLimit = 5; | |
| export const AllSuggestionsLimit = 1000; | |
| let codeOwnersModel: CodeOwnersModel | undefined; | |
| export interface OwnedPathsInfoOpt { | |
| oldPaths: Set<string>; | |
| newPaths: Set<string>; | |
| oldPathOwners: Map<string, Array<AccountInfo>>; | |
| newPathOwners: Map<string, Array<AccountInfo>>; | |
| } | |
| export interface CodeOwnersState { | |
| showSuggestions: boolean; | |
| selectedSuggestionsType: SuggestionsType; | |
| suggestionsByTypes: Map<SuggestionsType, Suggestion>; | |
| pluginStatus?: PluginStatus; | |
| branchConfig?: CodeOwnerBranchConfigInfo; | |
| ownedPaths?: OwnedPathsInfoOpt; | |
| userRole?: UserRole; | |
| areAllFilesApproved?: boolean; | |
| status?: Status; | |
| } | |
| /** | |
| * Maintain the state of code-owners. | |
| * Raises 'model-property-changed' event when a property is changed. | |
| * The plugin shares the same model between all UI elements (if it is not, | |
| * the plugin can't maintain showSuggestions state across different UI | |
| * elements). UI elements use values from this model to display information and | |
| * listens for the model-property-changed event. To do so, UI elements add | |
| * CodeOwnersModelMixin, which is doing the listening and the translation from | |
| * model-property-changed event to Polymer property-changed-event. The | |
| * translation allows to use model properties in observables, bindings, | |
| * computed properties, etc... | |
| * The CodeOwnersModelLoader updates the model. | |
| * | |
| * It would be good to use RxJs Observable for implementing model properties. | |
| * However, RxJs library is imported by Gerrit and there is no | |
| * good way to reuse the same library in the plugin. | |
| */ | |
| export class CodeOwnersModel extends EventTarget { | |
| private subject$: BehaviorSubject<CodeOwnersState> = new BehaviorSubject({ | |
| showSuggestions: false, | |
| selectedSuggestionsType: SuggestionsType.BEST_SUGGESTIONS, | |
| suggestionsByTypes: new Map(), | |
| } as CodeOwnersState); | |
| public state$: Observable<CodeOwnersState> = this.subject$.asObservable(); | |
| public showSuggestions$ = select(this.state$, state => state.showSuggestions); | |
| public selectedSuggestionsType$ = select( | |
| this.state$, | |
| state => state.selectedSuggestionsType | |
| ); | |
| public selectedSuggestionsFiles$ = select( | |
| this.state$, | |
| state => state.suggestionsByTypes.get(state.selectedSuggestionsType)?.files | |
| ); | |
| public selectedSuggestionsState$ = select( | |
| this.state$, | |
| state => state.suggestionsByTypes.get(state.selectedSuggestionsType)!.state | |
| ); | |
| public selectedSuggestionsLoadProgress$ = select( | |
| this.state$, | |
| state => | |
| state.suggestionsByTypes.get(state.selectedSuggestionsType)!.loadProgress | |
| ); | |
| public ownedPaths$ = select(this.state$, state => state.ownedPaths); | |
| constructor(readonly change: ChangeInfo) { | |
| super(); | |
| // Side-effectful initialization of state$ is ok because this happens | |
| // during construction time. | |
| const current = this.subject$.getValue(); | |
| for (const suggestionType of Object.values(SuggestionsType)) { | |
| current.suggestionsByTypes.set(suggestionType, { | |
| files: undefined, | |
| state: SuggestionsState.NotLoaded, | |
| loadProgress: undefined, | |
| }); | |
| } | |
| } | |
| get state() { | |
| return this.subject$.getValue(); | |
| } | |
| get selectedSuggestions() { | |
| const current = this.subject$.getValue(); | |
| return current.suggestionsByTypes.get(current.selectedSuggestionsType); | |
| } | |
| getSuggestionsByType(suggestionsType: SuggestionsType) { | |
| const current = this.subject$.getValue(); | |
| return Object.freeze(current.suggestionsByTypes.get(suggestionsType)!); | |
| } | |
| private setState(state: CodeOwnersState) { | |
| this.subject$.next(Object.freeze(state)); | |
| } | |
| setBranchConfig(branchConfig: CodeOwnerBranchConfigInfo) { | |
| const current = this.subject$.getValue(); | |
| if (current.branchConfig === branchConfig) return; | |
| this.setState({...current, branchConfig}); | |
| } | |
| setStatus(status: Status) { | |
| const current = this.subject$.getValue(); | |
| if (current.status === status) return; | |
| this.setState({...current, status}); | |
| } | |
| setUserRole(userRole: UserRole) { | |
| const current = this.subject$.getValue(); | |
| if (current.userRole === userRole) return; | |
| this.setState({...current, userRole}); | |
| } | |
| setAreAllFilesApproved(areAllFilesApproved: boolean) { | |
| const current = this.subject$.getValue(); | |
| if (current.areAllFilesApproved === areAllFilesApproved) return; | |
| this.setState({...current, areAllFilesApproved}); | |
| } | |
| setShowSuggestions(showSuggestions: boolean) { | |
| const current = this.subject$.getValue(); | |
| if (current.showSuggestions === showSuggestions) return; | |
| this.setState({...current, showSuggestions}); | |
| } | |
| setSelectedSuggestionType(selectedSuggestionsType: SuggestionsType) { | |
| const current = this.subject$.getValue(); | |
| if (current.selectedSuggestionsType === selectedSuggestionsType) return; | |
| this.setState({...current, selectedSuggestionsType}); | |
| } | |
| _setPluginStatus(pluginStatus: PluginStatus) { | |
| const current = this.subject$.getValue(); | |
| if (this._arePluginStatusesEqual(current.pluginStatus, pluginStatus)) | |
| return; | |
| this.setState({...current, pluginStatus}); | |
| } | |
| private updateSuggestion( | |
| suggestionsType: SuggestionsType, | |
| suggestionsUpdate: Partial<Suggestion> | |
| ) { | |
| const current = this.subject$.getValue(); | |
| const nextState = { | |
| ...current, | |
| suggestionsByTypes: new Map(current.suggestionsByTypes), | |
| }; | |
| nextState.suggestionsByTypes.set(suggestionsType, { | |
| ...nextState.suggestionsByTypes.get(suggestionsType)!, | |
| ...suggestionsUpdate, | |
| }); | |
| this.setState(nextState); | |
| } | |
| setSuggestionsFiles( | |
| suggestionsType: SuggestionsType, | |
| files: Array<FetchedFile> | |
| ) { | |
| const current = this.subject$.getValue(); | |
| const suggestions = current.suggestionsByTypes.get(suggestionsType); | |
| if (!suggestions) return; | |
| if (suggestions.files === files) return; | |
| if (deepEqual(suggestions.files, files)) return; | |
| this.updateSuggestion(suggestionsType, {files}); | |
| } | |
| setSuggestionsState( | |
| suggestionsType: SuggestionsType, | |
| state: SuggestionsState | |
| ) { | |
| const current = this.subject$.getValue(); | |
| const suggestions = current.suggestionsByTypes.get(suggestionsType); | |
| if (!suggestions) return; | |
| if (suggestions.state === state) return; | |
| this.updateSuggestion(suggestionsType, {state}); | |
| } | |
| setSuggestionsLoadProgress( | |
| suggestionsType: SuggestionsType, | |
| loadProgress: string | |
| ) { | |
| const current = this.subject$.getValue(); | |
| const suggestions = current.suggestionsByTypes.get(suggestionsType); | |
| if (!suggestions) return; | |
| if (suggestions.loadProgress === loadProgress) return; | |
| this.updateSuggestion(suggestionsType, {loadProgress}); | |
| } | |
| setOwnedPaths(ownedPathsInfo: OwnedPathsInfo | undefined) { | |
| const current = this.subject$.getValue(); | |
| const ownedPaths = { | |
| oldPaths: new Set<string>(), | |
| newPaths: new Set<string>(), | |
| oldPathOwners: new Map<string, Array<AccountInfo>>(), | |
| newPathOwners: new Map<string, Array<AccountInfo>>(), | |
| }; | |
| for (const changed_file of ownedPathsInfo?.owned_changed_files ?? []) { | |
| if (changed_file.old_path?.owned) | |
| ownedPaths.oldPaths.add(changed_file.old_path.path); | |
| if (changed_file.new_path?.owned) | |
| ownedPaths.newPaths.add(changed_file.new_path.path); | |
| if (changed_file.old_path?.owners) { | |
| ownedPaths.oldPathOwners.set( | |
| changed_file.old_path.path, | |
| ( | |
| ownedPaths.oldPathOwners.get(changed_file.old_path.path) ?? [] | |
| ).concat(changed_file.old_path.owners) | |
| ); | |
| } | |
| if (changed_file.new_path?.owners) { | |
| ownedPaths.newPathOwners.set( | |
| changed_file.new_path.path, | |
| ( | |
| ownedPaths.newPathOwners.get(changed_file.new_path.path) ?? [] | |
| ).concat(changed_file.new_path.owners) | |
| ); | |
| } | |
| } | |
| const nextState = { | |
| ...current, | |
| ownedPaths, | |
| }; | |
| this.setState(nextState); | |
| } | |
| setPluginEnabled(enabled: boolean) { | |
| this._setPluginStatus({ | |
| state: enabled ? PluginState.Enabled : PluginState.Disabled, | |
| }); | |
| } | |
| setServerConfigurationError(failedMessage: string) { | |
| this._setPluginStatus({ | |
| state: PluginState.ServerConfigurationError, | |
| failedMessage, | |
| }); | |
| } | |
| setPluginFailed(failedMessage: string) { | |
| this._setPluginStatus({state: PluginState.Failed, failedMessage}); | |
| } | |
| _arePluginStatusesEqual( | |
| status1: PluginStatus | undefined, | |
| status2: PluginStatus | undefined | |
| ) { | |
| if (status1 === undefined || status2 === undefined) { | |
| return status1 === status2; | |
| } | |
| if (status1.state !== status2.state) return false; | |
| return isPluginErrorState(status1.state) | |
| ? status1.failedMessage === status2.failedMessage | |
| : true; | |
| } | |
| static getModel(change: ChangeInfo) { | |
| if (!codeOwnersModel || codeOwnersModel.change !== change) { | |
| codeOwnersModel = new CodeOwnersModel(change); | |
| } | |
| return codeOwnersModel; | |
| } | |
| } |