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