Move status and revert loading from change view into models

Release-Notes: skip
Google-Bug-Id: b/247042673
Change-Id: I1f372b2ae62ba8fa1d978c726c29ede8754a5d2f
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index 8be2c51..4863852 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -48,6 +48,7 @@
   createEditUrl,
 } from '../views/change';
 import {NavigationService} from '../../elements/core/gr-navigation/gr-navigation';
+import {getRevertCreatedChangeIds} from '../../utils/message-util';
 
 export enum LoadingStatus {
   NOT_LOADED = 'NOT_LOADED',
@@ -332,6 +333,12 @@
     ([change, account]) => isOwner(change, account)
   );
 
+  public readonly messages$ = select(this.change$, change => change?.messages);
+
+  public readonly revertingChangeIds$ = select(this.messages$, messages =>
+    getRevertCreatedChangeIds(messages ?? [])
+  );
+
   // For usage in `combineLatest` we need `startWith` such that reload$ has an
   // initial value.
   readonly reload$: Observable<unknown> = fromEvent(document, 'reload').pipe(
diff --git a/polygerrit-ui/app/models/change/related-changes-model.ts b/polygerrit-ui/app/models/change/related-changes-model.ts
index 02d9f88..901999c 100644
--- a/polygerrit-ui/app/models/change/related-changes-model.ts
+++ b/polygerrit-ui/app/models/change/related-changes-model.ts
@@ -13,10 +13,11 @@
 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 {combineLatest, forkJoin, from, of} from 'rxjs';
+import {map, switchMap} from 'rxjs/operators';
 import {ConfigModel} from '../config/config-model';
 import {ChangeStatus} from '../../api/rest-api';
+import {isDefined} from '../../types/types';
 
 export interface RelatedChangesState {
   /** `undefined` means "not yet loaded". */
@@ -25,6 +26,7 @@
   cherryPicks?: ChangeInfo[];
   conflictingChanges?: ChangeInfo[];
   sameTopicChanges?: ChangeInfo[];
+  revertingChanges: ChangeInfo[];
 }
 
 const initialState: RelatedChangesState = {
@@ -33,6 +35,7 @@
   cherryPicks: undefined,
   conflictingChanges: undefined,
   sameTopicChanges: undefined,
+  revertingChanges: [],
 };
 
 export const relatedChangesModelToken = define<RelatedChangesModel>(
@@ -66,6 +69,32 @@
   );
 
   /**
+   * Emits all changes that have reverted the current change, based on
+   * information from parsed change messages. Abandoned changes are not
+   * included.
+   */
+  public readonly revertingChanges$ = select(
+    this.state$,
+    state => state.revertingChanges
+  );
+
+  /**
+   * Emits one reverting change (if there is any) from revertingChanges$.
+   * It prefers MERGED changes. Otherwise the choice is random.
+   */
+  public readonly revertingChange$ = select(
+    this.revertingChanges$,
+    revertingChanges => {
+      if (revertingChanges.length === 0) return undefined;
+      const submittedRevert = revertingChanges.find(
+        c => c.status === ChangeStatus.MERGED
+      );
+      if (submittedRevert) return submittedRevert;
+      return revertingChanges[0];
+    }
+  );
+
+  /**
    * 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.
@@ -93,6 +122,7 @@
       this.loadCherryPicks(),
       this.loadConflictingChanges(),
       this.loadSameTopicChanges(),
+      this.loadRevertingChanges(),
     ];
   }
 
@@ -197,4 +227,26 @@
         this.updateState({sameTopicChanges});
       });
   }
+
+  private loadRevertingChanges() {
+    return combineLatest([
+      this.changeModel.reload$,
+      this.changeModel.revertingChangeIds$,
+    ])
+      .pipe(
+        switchMap(([_, changeIds]) => {
+          if (!changeIds?.length) return of([]);
+          return forkJoin(
+            changeIds.map(changeId =>
+              from(this.restApiService.getChange(changeId))
+            )
+          );
+        }),
+        map(changes => changes.filter(isDefined)),
+        map(changes => changes.filter(c => c.status !== ChangeStatus.ABANDONED))
+      )
+      .subscribe(revertingChanges => {
+        this.updateState({revertingChanges});
+      });
+  }
 }
diff --git a/polygerrit-ui/app/models/change/related-changes-model_test.ts b/polygerrit-ui/app/models/change/related-changes-model_test.ts
index 31c0026..295f284 100644
--- a/polygerrit-ui/app/models/change/related-changes-model_test.ts
+++ b/polygerrit-ui/app/models/change/related-changes-model_test.ts
@@ -22,8 +22,10 @@
   createRelatedChangesInfo,
   createRelatedChangeAndCommitInfo,
   createChange,
+  createChangeMessage,
 } from '../../test/test-data-generators';
-import {ChangeStatus, TopicName} from '../../api/rest-api';
+import {ChangeStatus, ReviewInputTag, TopicName} from '../../api/rest-api';
+import {MessageTag} from '../../constants/constants';
 
 suite('related-changes-model tests', () => {
   let model: RelatedChangesModel;
@@ -235,4 +237,50 @@
       assert.isTrue(getChangesWithSameTopicStub.calledOnce);
     });
   });
+
+  suite('loadRevertingChanges', async () => {
+    let getChangeStub: SinonStub;
+
+    setup(() => {
+      getChangeStub = stubRestApi('getChange').callsFake(() =>
+        Promise.resolve(createChange())
+      );
+    });
+
+    test('revertingChanges initially empty', async () => {
+      await waitUntilObserved(
+        model.revertingChanges$,
+        revertingChanges => revertingChanges.length === 0
+      );
+      assert.isFalse(getChangeStub.called);
+    });
+
+    test('revertingChanges empty when change does not contain a revert message', async () => {
+      changeModel.updateStateChange(createParsedChange());
+      await waitUntilObserved(
+        model.revertingChanges$,
+        revertingChanges => revertingChanges.length === 0
+      );
+      assert.isFalse(getChangeStub.called);
+    });
+
+    test('revertingChanges emits', async () => {
+      changeModel.updateStateChange({
+        ...createParsedChange(),
+        messages: [
+          {
+            ...createChangeMessage(),
+            message: 'Created a revert of this change as 123',
+            tag: MessageTag.TAG_REVERT as ReviewInputTag,
+          },
+        ],
+      });
+
+      await waitUntilObserved(
+        model.revertingChanges$,
+        revertingChanges => revertingChanges?.length === 1
+      );
+      assert.isTrue(getChangeStub.calledOnce);
+    });
+  });
 });