Show which files are owned by the current user
Also move to @open-wc/testing. This change depends on
https://gerrit-review.googlesource.com/c/gerrit/+/345554
Google-Bug-Id: b/178474499
Screenshot: https://imgur.com/a/GcyNQpA
Release-Notes: skip
Change-Id: Ic06f4834e6959eea4dadfb5af2dedd550d1f2f10
diff --git a/web/code-owners-api.ts b/web/code-owners-api.ts
index 70573c1..117fe13 100644
--- a/web/code-owners-api.ts
+++ b/web/code-owners-api.ts
@@ -133,6 +133,21 @@
info: FetchedOwner;
status?: string;
}
+
+export interface OwnedPathInfo {
+ path: string;
+ owned?: boolean;
+}
+
+export interface OwnedChangedFileInfo {
+ new_path?: OwnedPathInfo;
+ old_path?: OwnedPathInfo;
+}
+
+export interface OwnedPathsInfo {
+ owned_changed_files?: Array<OwnedChangedFileInfo>;
+}
+
/**
* Responsible for communicating with the rest-api
*
@@ -145,7 +160,7 @@
* Send a get request and provides custom response-code handling
*/
private async get(url: string): Promise<unknown> {
- const errFn = (response?: Response|null, error?: Error) => {
+ const errFn = (response?: Response | null, error?: Error) => {
if (error) throw error;
if (response) throw new ResponseError(response);
throw new Error('Generic REST API error');
@@ -170,8 +185,18 @@
* https://gerrit.googlesource.com/plugins/code-owners/+/HEAD/resources/Documentation/rest-api.md#change-endpoints
*/
listOwnerStatus(changeId: NumericChangeId): Promise<CodeOwnerStatusInfo> {
- return this.get(`/changes/${changeId}/code_owners.status?limit=100000`) as
- Promise<CodeOwnerStatusInfo>;
+ return this.get(
+ `/changes/${changeId}/code_owners.status?limit=100000`
+ ) as Promise<CodeOwnerStatusInfo>;
+ }
+
+ /**
+ * Returns a promise fetching which files are owned by a given user.
+ */
+ listOwnedPaths(changeId: NumericChangeId, account: AccountInfo) {
+ return this.get(
+ `/changes/${changeId}/revisions/current/owned_paths?user=${account.email}&limit=10000`
+ ) as Promise<OwnedPathsInfo>;
}
/**
@@ -180,12 +205,15 @@
* @doc
* https://gerrit.googlesource.com/plugins/code-owners/+/HEAD/resources/Documentation/rest-api.md#list-code-owners-for-path-in-branch
*/
- listOwnersForPath(changeId: NumericChangeId, path: string, limit: number):
- Promise<CodeOwnersInfo> {
+ listOwnersForPath(
+ changeId: NumericChangeId,
+ path: string,
+ limit: number
+ ): Promise<CodeOwnersInfo> {
return this.get(
- `/changes/${changeId}/revisions/current/code_owners` +
- `/${encodeURIComponent(path)}?limit=${limit}&o=DETAILS`) as
- Promise<CodeOwnersInfo>;
+ `/changes/${changeId}/revisions/current/code_owners` +
+ `/${encodeURIComponent(path)}?limit=${limit}&o=DETAILS`
+ ) as Promise<CodeOwnersInfo>;
}
/**
@@ -196,9 +224,10 @@
*/
getConfigForPath(project: string, branch: string, path: string) {
return this.get(
- `/projects/${encodeURIComponent(project)}/` +
+ `/projects/${encodeURIComponent(project)}/` +
`branches/${encodeURIComponent(branch)}/` +
- `code_owners.config/${encodeURIComponent(path)}`);
+ `code_owners.config/${encodeURIComponent(path)}`
+ );
}
/**
@@ -209,11 +238,11 @@
*/
async getBranchConfig(project: RepoName, branch: BranchName) {
try {
- const config =
- (await this.get(
- `/projects/${encodeURIComponent(project)}/` +
- `branches/${encodeURIComponent(branch)}/` +
- `code_owners.branch_config`)) as CodeOwnerBranchConfigInfo;
+ const config = (await this.get(
+ `/projects/${encodeURIComponent(project)}/` +
+ `branches/${encodeURIComponent(branch)}/` +
+ `code_owners.branch_config`
+ )) as CodeOwnerBranchConfigInfo;
return config;
} catch (err) {
if (err instanceof ResponseError) {
@@ -249,11 +278,14 @@
private promises = new Map<string, Promise<unknown>>();
constructor(
- private readonly codeOwnerApi: CodeOwnersApi,
- private readonly change: ChangeInfo) {}
+ private readonly codeOwnerApi: CodeOwnersApi,
+ private readonly change: ChangeInfo
+ ) {}
- private fetchOnce(cacheKey: string, asyncFn: () => Promise<unknown>):
- Promise<unknown> {
+ private fetchOnce(
+ cacheKey: string,
+ asyncFn: () => Promise<unknown>
+ ): Promise<unknown> {
let promise = this.promises.get(cacheKey);
if (promise) return promise;
promise = asyncFn();
@@ -261,9 +293,10 @@
return promise;
}
- getAccount(): Promise<AccountDetailInfo|undefined> {
- return this.fetchOnce('getAccount', () => this.getAccountImpl()) as
- Promise<AccountDetailInfo|undefined>;
+ getAccount(): Promise<AccountDetailInfo | undefined> {
+ return this.fetchOnce('getAccount', () => this.getAccountImpl()) as Promise<
+ AccountDetailInfo | undefined
+ >;
}
private async getAccountImpl() {
@@ -273,17 +306,22 @@
}
listOwnerStatus(): Promise<CodeOwnerStatusInfo> {
- return this.fetchOnce(
- 'listOwnerStatus',
- () => this.codeOwnerApi.listOwnerStatus(this.change._number)) as
- Promise<CodeOwnerStatusInfo>;
+ return this.fetchOnce('listOwnerStatus', () =>
+ this.codeOwnerApi.listOwnerStatus(this.change._number)
+ ) as Promise<CodeOwnerStatusInfo>;
+ }
+
+ async listOwnedPaths(): Promise<OwnedPathsInfo | undefined> {
+ const account = await this.getAccount();
+ if (!account) return undefined;
+ return this.fetchOnce('listOwnedPaths', () =>
+ this.codeOwnerApi.listOwnedPaths(this.change._number, account)
+ ) as Promise<OwnedPathsInfo>;
}
getBranchConfig(): Promise<CodeOwnerBranchConfigInfo> {
- return this.fetchOnce(
- 'getBranchConfig',
- () => this.codeOwnerApi.getBranchConfig(
- this.change.project, this.change.branch)) as
- Promise<CodeOwnerBranchConfigInfo>;
+ return this.fetchOnce('getBranchConfig', () =>
+ this.codeOwnerApi.getBranchConfig(this.change.project, this.change.branch)
+ ) as Promise<CodeOwnerBranchConfigInfo>;
}
}
diff --git a/web/code-owners-model-loader.ts b/web/code-owners-model-loader.ts
index cecb57b..c9a79e9 100644
--- a/web/code-owners-model-loader.ts
+++ b/web/code-owners-model-loader.ts
@@ -83,6 +83,14 @@
);
}
+ async loadOwnedPaths() {
+ await this._loadProperty(
+ 'ownedPaths',
+ () => this.ownersService.getOwnedPaths(),
+ paths => this.ownersModel.setOwnedPaths(paths)
+ );
+ }
+
async loadUserRole() {
await this._loadProperty(
'userRole',
diff --git a/web/code-owners-model-mixin.ts b/web/code-owners-model-mixin.ts
index dbbb2f6..18f5e51 100644
--- a/web/code-owners-model-mixin.ts
+++ b/web/code-owners-model-mixin.ts
@@ -19,6 +19,7 @@
import {ModelLoader} from './code-owners-model-loader';
import {
CodeOwnersModel,
+ OwnedPathsInfoOpt,
PluginStatus,
Status,
SuggestionsState,
@@ -53,6 +54,7 @@
selectedSuggestionsFiles?: Array<FetchedFile>;
selectedSuggestionsState?: SuggestionsState;
selectedSuggestionsLoadProgress?: string;
+ ownedPaths?: OwnedPathsInfoOpt;
loadPropertiesAfterModelChanged(): void;
}
@@ -112,6 +114,9 @@
@state()
selectedSuggestionsLoadProgress?: string;
+ @state()
+ ownedPaths?: OwnedPathsInfoOpt;
+
private model_?: CodeOwnersModel;
private subscriptions: Array<Subscription> = [];
@@ -142,12 +147,12 @@
);
this.subscriptions.push(
model.state$.subscribe(s => {
- this.userRole = s.userRole;
+ this.status = s.status;
})
);
this.subscriptions.push(
model.state$.subscribe(s => {
- this.status = s.status;
+ this.userRole = s.userRole;
})
);
this.subscriptions.push(
@@ -180,6 +185,11 @@
this.selectedSuggestionsState = s;
})
);
+ this.subscriptions.push(
+ model.ownedPaths$.subscribe(s => {
+ this.ownedPaths = s;
+ })
+ );
this.loadPropertiesAfterModelChanged();
}
diff --git a/web/code-owners-model.ts b/web/code-owners-model.ts
index 3e8d8ff..324c3e2 100644
--- a/web/code-owners-model.ts
+++ b/web/code-owners-model.ts
@@ -23,6 +23,7 @@
FetchedFile,
FileCodeOwnerStatusInfo,
OwnerStatus,
+ OwnedPathsInfo,
} from './code-owners-api';
import {deepEqual} from './deep-util';
import {select} from './observable-util';
@@ -48,8 +49,9 @@
export function isPluginErrorState(state: PluginState) {
return (
- state === PluginState.ServerConfigurationError ||
- state === PluginState.Failed);
+ state === PluginState.ServerConfigurationError ||
+ state === PluginState.Failed
+ );
}
export enum SuggestionsType {
@@ -87,14 +89,19 @@
export interface FileStatus {
changeType: ChangeType;
status: OwnerStatus;
- newPath?: string|null;
- oldPath?: string|null;
+ newPath?: string | null;
+ oldPath?: string | null;
}
export const BestSuggestionsLimit = 5;
export const AllSuggestionsLimit = 1000;
-let codeOwnersModel: CodeOwnersModel|undefined;
+let codeOwnersModel: CodeOwnersModel | undefined;
+
+export interface OwnedPathsInfoOpt {
+ oldPaths: Set<string>;
+ newPaths: Set<string>;
+}
export interface CodeOwnersState {
showSuggestions: boolean;
@@ -102,6 +109,7 @@
suggestionsByTypes: Map<SuggestionsType, Suggestion>;
pluginStatus?: PluginStatus;
branchConfig?: CodeOwnerBranchConfigInfo;
+ ownedPaths?: OwnedPathsInfoOpt;
userRole?: UserRole;
areAllFilesApproved?: boolean;
status?: Status;
@@ -135,23 +143,28 @@
public showSuggestions$ = select(this.state$, state => state.showSuggestions);
- public selectedSuggestionsType$ =
- select(this.state$, state => state.selectedSuggestionsType);
+ public selectedSuggestionsType$ = select(
+ this.state$,
+ state => state.selectedSuggestionsType
+ );
public selectedSuggestionsFiles$ = select(
- this.state$,
- state =>
- state.suggestionsByTypes.get(state.selectedSuggestionsType)?.files);
+ this.state$,
+ state => state.suggestionsByTypes.get(state.selectedSuggestionsType)?.files
+ );
public selectedSuggestionsState$ = select(
- this.state$,
- state =>
- state.suggestionsByTypes.get(state.selectedSuggestionsType)!.state);
+ this.state$,
+ state => state.suggestionsByTypes.get(state.selectedSuggestionsType)!.state
+ );
public selectedSuggestionsLoadProgress$ = select(
- this.state$,
- state => state.suggestionsByTypes.get(state.selectedSuggestionsType)!
- .loadProgress);
+ this.state$,
+ state =>
+ state.suggestionsByTypes.get(state.selectedSuggestionsType)!.loadProgress
+ );
+
+ public ownedPaths$ = select(this.state$, state => state.ownedPaths);
constructor(readonly change: ChangeInfo) {
super();
@@ -229,8 +242,9 @@
}
private updateSuggestion(
- suggestionsType: SuggestionsType,
- suggestionsUpdate: Partial<Suggestion>) {
+ suggestionsType: SuggestionsType,
+ suggestionsUpdate: Partial<Suggestion>
+ ) {
const current = this.subject$.getValue();
const nextState = {
...current,
@@ -244,7 +258,9 @@
}
setSuggestionsFiles(
- suggestionsType: SuggestionsType, files: Array<FetchedFile>) {
+ suggestionsType: SuggestionsType,
+ files: Array<FetchedFile>
+ ) {
const current = this.subject$.getValue();
const suggestions = current.suggestionsByTypes.get(suggestionsType);
if (!suggestions) return;
@@ -254,7 +270,9 @@
}
setSuggestionsState(
- suggestionsType: SuggestionsType, state: SuggestionsState) {
+ suggestionsType: SuggestionsType,
+ state: SuggestionsState
+ ) {
const current = this.subject$.getValue();
const suggestions = current.suggestionsByTypes.get(suggestionsType);
if (!suggestions) return;
@@ -263,7 +281,9 @@
}
setSuggestionsLoadProgress(
- suggestionsType: SuggestionsType, loadProgress: string) {
+ suggestionsType: SuggestionsType,
+ loadProgress: string
+ ) {
const current = this.subject$.getValue();
const suggestions = current.suggestionsByTypes.get(suggestionsType);
if (!suggestions) return;
@@ -271,6 +291,25 @@
this.updateSuggestion(suggestionsType, {loadProgress});
}
+ setOwnedPaths(ownedPathsInfo: OwnedPathsInfo | undefined) {
+ const current = this.subject$.getValue();
+ const ownedPaths = {
+ oldPaths: new Set<string>(),
+ newPaths: new Set<string>(),
+ };
+ 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);
+ }
+ const nextState = {
+ ...current,
+ ownedPaths,
+ };
+ this.setState(nextState);
+ }
+
setPluginEnabled(enabled: boolean) {
this._setPluginStatus({
state: enabled ? PluginState.Enabled : PluginState.Disabled,
@@ -289,14 +328,16 @@
}
_arePluginStatusesEqual(
- status1: PluginStatus|undefined, status2: PluginStatus|undefined) {
+ 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;
+ return isPluginErrorState(status1.state)
+ ? status1.failedMessage === status2.failedMessage
+ : true;
}
static getModel(change: ChangeInfo) {
diff --git a/web/code-owners-service.ts b/web/code-owners-service.ts
index a7206f0..599c49b 100644
--- a/web/code-owners-service.ts
+++ b/web/code-owners-service.ts
@@ -95,14 +95,18 @@
}
/**
+ * Fetch the account.
+ */
+ getAccount(): Promise<AccountDetailInfo | undefined> {
+ return this.codeOwnersCacheApi.getAccount();
+ }
+
+ /**
* Prefetch data
*/
async prefetch() {
try {
- await Promise.all([
- this.codeOwnersCacheApi.getAccount(),
- this.getStatus(),
- ]);
+ await Promise.all([this.getAccount(), this.getStatus()]);
} catch {
// Ignore any errors during prefetch.
// The same call from a different place throws the same exception
@@ -117,7 +121,7 @@
* role 'REVIEWER' remains unchanged until the change view is reloaded.
*/
async getLoggedInUserInitialRole(): Promise<UserRole> {
- const account = await this.codeOwnersCacheApi.getAccount();
+ const account = await this.getAccount();
if (!account) {
return UserRole.ANONYMOUS;
}
@@ -141,18 +145,18 @@
return UserRole.CHANGE_OWNER;
}
if (change.reviewers) {
- if (this._accountInReviewers(change.reviewers.REVIEWER, account)) {
+ if (this.accountInReviewers(change.reviewers.REVIEWER, account)) {
return UserRole.REVIEWER;
- } else if (this._accountInReviewers(change.reviewers.CC, account)) {
+ } else if (this.accountInReviewers(change.reviewers.CC, account)) {
return UserRole.CC;
- } else if (this._accountInReviewers(change.reviewers.REMOVED, account)) {
+ } else if (this.accountInReviewers(change.reviewers.REMOVED, account)) {
return UserRole.REMOVED_REVIEWER;
}
}
return UserRole.OTHER;
}
- _accountInReviewers(
+ private accountInReviewers(
reviewers: AccountInfo[] | undefined,
account: AccountDetailInfo
) {
@@ -222,6 +226,13 @@
}
/**
+ * Gets which files are owned by the given user.
+ */
+ async getOwnedPaths() {
+ return this.codeOwnersCacheApi.listOwnedPaths();
+ }
+
+ /**
* Gets owner suggestions.
*/
async getSuggestedOwners(suggestionsType: SuggestionsType) {
diff --git a/web/code-owners-service_test.ts b/web/code-owners-service_test.ts
index e764de5..b1648a3 100644
--- a/web/code-owners-service_test.ts
+++ b/web/code-owners-service_test.ts
@@ -1,4 +1,3 @@
-import './test-setup.js';
import {CodeOwnerService} from './code-owners-service.js';
import {
RequestPayload,
@@ -9,6 +8,7 @@
HttpMethod,
} from '@gerritcodereview/typescript-api/rest-api.js';
import {SuggestionsType} from './code-owners-model.js';
+import {assert} from '@open-wc/testing';
function flush() {
return new Promise((resolve, _reject) => {
diff --git a/web/owner-status-column.ts b/web/owner-status-column.ts
index e426ce0..2ec88ef 100644
--- a/web/owner-status-column.ts
+++ b/web/owner-status-column.ts
@@ -56,7 +56,7 @@
[STATUS_CODE.PENDING]: 'schedule',
[STATUS_CODE.MISSING]: 'close',
[STATUS_CODE.PENDING_OLD_PATH]: 'schedule',
- [STATUS_CODE.MISSING_OLD_PATH]: ':close',
+ [STATUS_CODE.MISSING_OLD_PATH]: 'close',
[STATUS_CODE.APPROVED]: 'check',
[STATUS_CODE.ERROR]: 'info',
};
@@ -73,6 +73,17 @@
[STATUS_CODE.ERROR_OLD_PATH]: 'Failed to fetch code owner status',
};
+export function hasPath(ownedPaths: Set<string>, path: string | undefined) {
+ if (!path) return false;
+ if (path.charAt(0) === '/') {
+ if (ownedPaths.has(path)) return true;
+ } else {
+ // NOTE: The backend returns absolute paths.
+ if (ownedPaths.has('/' + path)) return true;
+ }
+ return false;
+}
+
const base = CodeOwnersModelMixin(LitElement);
class BaseEl extends base {
@@ -160,7 +171,7 @@
return [
css`
:host {
- display: block;
+ display: flex;
padding-right: var(--spacing-m);
width: 3em;
text-align: center;
@@ -168,14 +179,14 @@
gr-icon {
padding: var(--spacing-xs) 0px;
}
- :host([owner-status='approved']) gr-icon {
+ :host([owner-status='approved']) gr-icon.status {
color: var(--positive-green-text-color);
}
- :host([owner-status='pending']) gr-icon {
+ :host([owner-status='pending']) gr-icon.status {
color: #ffa62f;
}
- :host([owner-status='missing']) gr-icon,
- :host([owner-status='error']) gr-icon {
+ :host([owner-status='missing']) gr-icon.status,
+ :host([owner-status='error']) gr-icon.status {
color: var(--negative-red-text-color);
}
`,
@@ -189,19 +200,42 @@
override render() {
if (this.computeHidden() || this.status === undefined) return nothing;
+ return html`${this.renderStatus()}${this.renderOwnership()}`;
+ }
+ private renderStatus() {
const statusInfo = this.computeTooltip();
- const statusIcon = this.computeIcon();
+ const statusIcon = this.computeStatusIcon();
return html`
<gr-tooltip-content title=${statusInfo} has-tooltip>
- <gr-icon icon=${statusIcon}></gr-icon>
+ <gr-icon class="status" icon=${statusIcon}></gr-icon>
</gr-tooltip-content>
`;
}
+ private renderOwnership() {
+ if (!this.isOwned()) return nothing;
+ return html`
+ <gr-tooltip-content title="You own this file" has-tooltip>
+ <gr-icon filled icon="policy"></gr-icon>
+ </gr-tooltip-content>
+ `;
+ }
+
+ private isOwned() {
+ if (!this.ownedPaths) return false;
+ if (
+ hasPath(this.ownedPaths.newPaths, this.path) ||
+ hasPath(this.ownedPaths.oldPaths, this.oldPath)
+ )
+ return true;
+ return false;
+ }
+
override loadPropertiesAfterModelChanged() {
super.loadPropertiesAfterModelChanged();
this.modelLoader?.loadStatus();
+ this.modelLoader?.loadOwnedPaths();
}
private computeStatus() {
@@ -237,7 +271,7 @@
);
}
- private computeIcon() {
+ private computeStatusIcon() {
return STATUS_ICON[this.ownerStatus!];
}
diff --git a/web/owner-status-column_test.ts b/web/owner-status-column_test.ts
new file mode 100644
index 0000000..2fd223a
--- /dev/null
+++ b/web/owner-status-column_test.ts
@@ -0,0 +1,10 @@
+import {assert} from '@open-wc/testing';
+
+import {hasPath} from './owner-status-column';
+
+suite('owner-status-column', () => {
+ test('hasPath', () => {
+ assert(hasPath(new Set(['/COMMIT']), '/COMMIT'));
+ assert(hasPath(new Set(['/some/file']), 'some/file'));
+ });
+});
diff --git a/web/test-setup.ts b/web/test-setup.ts
deleted file mode 100644
index 2a08ab1..0000000
--- a/web/test-setup.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import 'chai/chai';
-declare global {
- interface Window {
- assert: typeof chai.assert;
- expect: typeof chai.expect;
- sinon: typeof sinon;
- }
- let assert: typeof chai.assert;
- let expect: typeof chai.expect;
- let sinon: typeof sinon;
-}
-window.assert = chai.assert;
-window.expect = chai.expect;
-window.sinon = sinon;