Move loading of related changes into a new model
Release-Notes: skip
Google-Bug-Id: b/247042673
Change-Id: Ib46587cb011dac9905f375f77fe9aa2302f815d9
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index da88bfc..8be2c51 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -215,8 +215,14 @@
public readonly changeNum$ = select(this.change$, change => change?._number);
+ public readonly changeId$ = select(this.change$, change => change?.change_id);
+
public readonly repo$ = select(this.change$, change => change?.project);
+ public readonly topic$ = select(this.change$, change => change?.topic);
+
+ public readonly status$ = select(this.change$, change => change?.status);
+
public readonly labels$ = select(this.change$, change => change?.labels);
public readonly revisions$ = select(this.change$, change =>
diff --git a/polygerrit-ui/app/models/change/related-changes-model.ts b/polygerrit-ui/app/models/change/related-changes-model.ts
new file mode 100644
index 0000000..02d9f88
--- /dev/null
+++ b/polygerrit-ui/app/models/change/related-changes-model.ts
@@ -0,0 +1,200 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+ ChangeInfo,
+ RelatedChangeAndCommitInfo,
+ SubmittedTogetherInfo,
+} from '../../types/common';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {select} from '../../utils/observable-util';
+import {Model} from '../model';
+import {define} from '../dependency';
+import {ChangeModel} from './change-model';
+import {combineLatest, from, of} from 'rxjs';
+import {switchMap} from 'rxjs/operators';
+import {ConfigModel} from '../config/config-model';
+import {ChangeStatus} from '../../api/rest-api';
+
+export interface RelatedChangesState {
+ /** `undefined` means "not yet loaded". */
+ relatedChanges?: RelatedChangeAndCommitInfo[];
+ submittedTogether?: SubmittedTogetherInfo;
+ cherryPicks?: ChangeInfo[];
+ conflictingChanges?: ChangeInfo[];
+ sameTopicChanges?: ChangeInfo[];
+}
+
+const initialState: RelatedChangesState = {
+ relatedChanges: undefined,
+ submittedTogether: undefined,
+ cherryPicks: undefined,
+ conflictingChanges: undefined,
+ sameTopicChanges: undefined,
+};
+
+export const relatedChangesModelToken = define<RelatedChangesModel>(
+ 'related-changes-model'
+);
+
+export class RelatedChangesModel extends Model<RelatedChangesState> {
+ public readonly relatedChanges$ = select(
+ this.state$,
+ state => state.relatedChanges
+ );
+
+ public readonly submittedTogether$ = select(
+ this.state$,
+ state => state.submittedTogether
+ );
+
+ public readonly cherryPicks$ = select(
+ this.state$,
+ state => state.cherryPicks
+ );
+
+ public readonly conflictingChanges$ = select(
+ this.state$,
+ state => state.conflictingChanges
+ );
+
+ public readonly sameTopicChanges$ = select(
+ this.state$,
+ state => state.sameTopicChanges
+ );
+
+ /**
+ * Determines whether the change has a parent change. If there
+ * is a relation chain, and the change id is not the last item of the
+ * relation chain, then there is a parent.
+ */
+ public readonly hasParent$ = select(
+ combineLatest([this.changeModel.change$, this.relatedChanges$]),
+ ([change, relatedChanges]) => {
+ if (!change) return undefined;
+ if (relatedChanges === undefined) return undefined;
+ if (relatedChanges.length === 0) return false;
+ const lastChangeId = relatedChanges[relatedChanges.length - 1].change_id;
+ return lastChangeId !== change.change_id;
+ }
+ );
+
+ constructor(
+ readonly changeModel: ChangeModel,
+ readonly configModel: ConfigModel,
+ readonly restApiService: RestApiService
+ ) {
+ super(initialState);
+ this.subscriptions = [
+ this.loadRelatedChanges(),
+ this.loadSubmittedTogether(),
+ this.loadCherryPicks(),
+ this.loadConflictingChanges(),
+ this.loadSameTopicChanges(),
+ ];
+ }
+
+ private loadRelatedChanges() {
+ return combineLatest([
+ this.changeModel.reload$,
+ this.changeModel.changeNum$,
+ this.changeModel.latestPatchNum$,
+ ])
+ .pipe(
+ switchMap(([_, changeNum, latestPatchNum]) => {
+ if (!changeNum || !latestPatchNum) return of(undefined);
+ return from(
+ this.restApiService
+ .getRelatedChanges(changeNum, latestPatchNum)
+ .then(info => info?.changes ?? [])
+ );
+ })
+ )
+ .subscribe(relatedChanges => {
+ this.updateState({relatedChanges});
+ });
+ }
+
+ private loadSubmittedTogether() {
+ return combineLatest([
+ this.changeModel.reload$,
+ this.changeModel.changeNum$,
+ ])
+ .pipe(
+ switchMap(([_, changeNum]) => {
+ if (!changeNum) return of(undefined);
+ return from(
+ this.restApiService.getChangesSubmittedTogether(changeNum)
+ );
+ })
+ )
+ .subscribe(submittedTogether => {
+ this.updateState({submittedTogether});
+ });
+ }
+
+ private loadCherryPicks() {
+ return combineLatest([
+ this.changeModel.reload$,
+ this.changeModel.changeNum$,
+ this.changeModel.changeId$,
+ this.changeModel.repo$,
+ ])
+ .pipe(
+ switchMap(([_, changeNum, changeId, repo]) => {
+ if (!changeNum || !changeId || !repo) return of(undefined);
+ return from(
+ this.restApiService.getChangeCherryPicks(repo, changeId, changeNum)
+ );
+ })
+ )
+ .subscribe(cherryPicks => {
+ this.updateState({cherryPicks});
+ });
+ }
+
+ private loadConflictingChanges() {
+ return combineLatest([
+ this.changeModel.reload$,
+ this.changeModel.changeNum$,
+ this.changeModel.status$,
+ this.changeModel.mergeable$,
+ ])
+ .pipe(
+ switchMap(([_, changeNum, status, mergeable]) => {
+ if (!changeNum || !status || !mergeable) return of(undefined);
+ if (status !== ChangeStatus.NEW) return of(undefined);
+ return from(this.restApiService.getChangeConflicts(changeNum));
+ })
+ )
+ .subscribe(conflictingChanges => {
+ this.updateState({conflictingChanges});
+ });
+ }
+
+ private loadSameTopicChanges() {
+ return combineLatest([
+ this.changeModel.reload$,
+ this.changeModel.changeNum$,
+ this.changeModel.topic$,
+ this.configModel.serverConfig$,
+ ])
+ .pipe(
+ switchMap(([_, changeNum, topic, config]) => {
+ if (!changeNum || !topic || !config) return of(undefined);
+ if (config.change.submit_whole_topic) return of(undefined);
+ return from(
+ this.restApiService.getChangesWithSameTopic(topic, {
+ openChangesOnly: true,
+ changeToExclude: changeNum,
+ })
+ );
+ })
+ )
+ .subscribe(sameTopicChanges => {
+ this.updateState({sameTopicChanges});
+ });
+ }
+}
diff --git a/polygerrit-ui/app/models/change/related-changes-model_test.ts b/polygerrit-ui/app/models/change/related-changes-model_test.ts
new file mode 100644
index 0000000..31c0026
--- /dev/null
+++ b/polygerrit-ui/app/models/change/related-changes-model_test.ts
@@ -0,0 +1,238 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import {getAppContext} from '../../services/app-context';
+import {ChangeModel, changeModelToken} from '../change/change-model';
+import {assert} from '@open-wc/testing';
+import {testResolver} from '../../test/common-test-setup';
+import {RelatedChangesModel} from './related-changes-model';
+import {configModelToken} from '../config/config-model';
+import {SinonStub} from 'sinon';
+import {
+ ChangeInfo,
+ RelatedChangesInfo,
+ SubmittedTogetherInfo,
+} from '../../types/common';
+import {stubRestApi, waitUntilObserved} from '../../test/test-utils';
+import {
+ createParsedChange,
+ createRelatedChangesInfo,
+ createRelatedChangeAndCommitInfo,
+ createChange,
+} from '../../test/test-data-generators';
+import {ChangeStatus, TopicName} from '../../api/rest-api';
+
+suite('related-changes-model tests', () => {
+ let model: RelatedChangesModel;
+ let changeModel: ChangeModel;
+
+ setup(async () => {
+ changeModel = testResolver(changeModelToken);
+ model = new RelatedChangesModel(
+ changeModel,
+ testResolver(configModelToken),
+ getAppContext().restApiService
+ );
+ await waitUntilObserved(changeModel.change$, c => c === undefined);
+ });
+
+ teardown(() => {
+ model.finalize();
+ });
+
+ test('register and fetch', async () => {
+ assert.equal('', '');
+ });
+
+ suite('related changes and hasParent', async () => {
+ let getRelatedChangesStub: SinonStub;
+ let getRelatedChangesResponse: RelatedChangesInfo;
+ let hasParent: boolean | undefined;
+
+ setup(() => {
+ getRelatedChangesStub = stubRestApi('getRelatedChanges').callsFake(() =>
+ Promise.resolve(getRelatedChangesResponse)
+ );
+ model.hasParent$.subscribe(x => (hasParent = x));
+ });
+
+ test('relatedChanges initially undefined', async () => {
+ await waitUntilObserved(
+ model.relatedChanges$,
+ relatedChanges => relatedChanges === undefined
+ );
+ assert.isFalse(getRelatedChangesStub.called);
+ assert.isUndefined(hasParent);
+ });
+
+ test('relatedChanges loading empty', async () => {
+ changeModel.updateStateChange({...createParsedChange()});
+
+ await waitUntilObserved(
+ model.relatedChanges$,
+ relatedChanges => relatedChanges?.length === 0
+ );
+ assert.isTrue(getRelatedChangesStub.calledOnce);
+ assert.isFalse(hasParent);
+ });
+
+ test('relatedChanges loading one change', async () => {
+ getRelatedChangesResponse = {
+ ...createRelatedChangesInfo(),
+ changes: [createRelatedChangeAndCommitInfo()],
+ };
+ changeModel.updateStateChange({...createParsedChange()});
+
+ await waitUntilObserved(
+ model.relatedChanges$,
+ relatedChanges => relatedChanges?.length === 1
+ );
+ assert.isTrue(getRelatedChangesStub.calledOnce);
+ assert.isTrue(hasParent);
+ });
+ });
+
+ suite('loadSubmittedTogether', async () => {
+ let getChangesSubmittedTogetherStub: SinonStub;
+ let getChangesSubmittedTogetherResponse: SubmittedTogetherInfo;
+
+ setup(() => {
+ getChangesSubmittedTogetherStub = stubRestApi(
+ 'getChangesSubmittedTogether'
+ ).callsFake(() => Promise.resolve(getChangesSubmittedTogetherResponse));
+ });
+
+ test('submittedTogether initially undefined', async () => {
+ await waitUntilObserved(
+ model.submittedTogether$,
+ submittedTogether => submittedTogether === undefined
+ );
+ assert.isFalse(getChangesSubmittedTogetherStub.called);
+ });
+
+ test('submittedTogether emits', async () => {
+ getChangesSubmittedTogetherResponse = {
+ changes: [createChange()],
+ non_visible_changes: 0,
+ };
+ changeModel.updateStateChange({...createParsedChange()});
+
+ await waitUntilObserved(
+ model.submittedTogether$,
+ submittedTogether => submittedTogether?.changes?.length === 1
+ );
+ assert.isTrue(getChangesSubmittedTogetherStub.calledOnce);
+ });
+ });
+
+ suite('loadCherryPicks', async () => {
+ let getChangeCherryPicksStub: SinonStub;
+ let getChangeCherryPicksResponse: ChangeInfo[];
+
+ setup(() => {
+ getChangeCherryPicksStub = stubRestApi('getChangeCherryPicks').callsFake(
+ () => Promise.resolve(getChangeCherryPicksResponse)
+ );
+ });
+
+ test('cherryPicks initially undefined', async () => {
+ await waitUntilObserved(
+ model.cherryPicks$,
+ cherryPicks => cherryPicks === undefined
+ );
+ assert.isFalse(getChangeCherryPicksStub.called);
+ });
+
+ test('cherryPicks emits', async () => {
+ getChangeCherryPicksResponse = [createChange()];
+ changeModel.updateStateChange({...createParsedChange()});
+
+ await waitUntilObserved(
+ model.cherryPicks$,
+ cherryPicks => cherryPicks?.length === 1
+ );
+ assert.isTrue(getChangeCherryPicksStub.calledOnce);
+ });
+ });
+
+ suite('loadConflictingChanges', async () => {
+ let getChangeConflictsStub: SinonStub;
+ let getChangeConflictsResponse: ChangeInfo[];
+
+ setup(() => {
+ getChangeConflictsStub = stubRestApi('getChangeConflicts').callsFake(() =>
+ Promise.resolve(getChangeConflictsResponse)
+ );
+ });
+
+ test('conflictingChanges initially undefined', async () => {
+ await waitUntilObserved(
+ model.conflictingChanges$,
+ conflictingChanges => conflictingChanges === undefined
+ );
+ assert.isFalse(getChangeConflictsStub.called);
+ });
+
+ test('conflictingChanges not loaded for merged changes', async () => {
+ getChangeConflictsResponse = [createChange()];
+ changeModel.updateStateChange({
+ ...createParsedChange(),
+ mergeable: true,
+ status: ChangeStatus.MERGED,
+ });
+
+ await waitUntilObserved(
+ model.conflictingChanges$,
+ conflictingChanges => conflictingChanges === undefined
+ );
+ assert.isFalse(getChangeConflictsStub.called);
+ });
+
+ test('conflictingChanges emits', async () => {
+ getChangeConflictsResponse = [createChange()];
+ changeModel.updateStateChange({...createParsedChange(), mergeable: true});
+
+ await waitUntilObserved(
+ model.conflictingChanges$,
+ conflictingChanges => conflictingChanges?.length === 1
+ );
+ assert.isTrue(getChangeConflictsStub.calledOnce);
+ });
+ });
+
+ suite('loadSameTopicChanges', async () => {
+ let getChangesWithSameTopicStub: SinonStub;
+ let getChangesWithSameTopicResponse: ChangeInfo[];
+
+ setup(() => {
+ getChangesWithSameTopicStub = stubRestApi(
+ 'getChangesWithSameTopic'
+ ).callsFake(() => Promise.resolve(getChangesWithSameTopicResponse));
+ });
+
+ test('sameTopicChanges initially undefined', async () => {
+ await waitUntilObserved(
+ model.sameTopicChanges$,
+ sameTopicChanges => sameTopicChanges === undefined
+ );
+ assert.isFalse(getChangesWithSameTopicStub.called);
+ });
+
+ test('sameTopicChanges emits', async () => {
+ getChangesWithSameTopicResponse = [createChange()];
+ changeModel.updateStateChange({
+ ...createParsedChange(),
+ topic: 'test-topic' as TopicName,
+ });
+
+ await waitUntilObserved(
+ model.sameTopicChanges$,
+ sameTopicChanges => sameTopicChanges?.length === 1
+ );
+ assert.isTrue(getChangesWithSameTopicStub.calledOnce);
+ });
+ });
+});