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);
+    });
+  });
+});