Merge "Add BEFORE_PUBLISH_EDIT plugin event"
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index 7695b60..6f6dfb6 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -43,6 +43,7 @@
   SHOW_DIFF = 'showdiff',
   BEFORE_REPLY_SENT = 'before-reply-sent',
   REPLY_SENT = 'replysent',
+  BEFORE_PUBLISH_EDIT = 'before-publish-edit',
   PUBLISH_EDIT = 'publish-edit',
 }
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 46da136..12e2715 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -1510,7 +1510,7 @@
         this.handleMoveTap();
         break;
       case ChangeActions.PUBLISH_EDIT:
-        this.handlePublishEditTap();
+        await this.handlePublishEditTap();
         break;
       case ChangeActions.REBASE_EDIT:
         this.handleRebaseEditTap();
@@ -1770,7 +1770,7 @@
     );
   }
 
-  private handlePublishEditConfirm() {
+  private async handlePublishEditConfirm() {
     this.hideAllDialogs();
 
     if (!this.actions.publishEdit) return;
@@ -1779,6 +1779,15 @@
     // edit are deleted.
     this.getStorage().eraseEditableContentItemsForChangeEdit(this.changeNum);
 
+    if (
+      !(await this.getPluginLoader().jsApiService.handleBeforePublishEdit(
+        this.change as ChangeInfo
+      ))
+    ) {
+      // Exit early and abort publish if a plugin hook requests it.
+      return;
+    }
+
     this.fireAction(
       '/edit:publish',
       assertUIActionInfo(this.actions.publishEdit),
@@ -2142,7 +2151,7 @@
     this.fireAction('/wip', assertUIActionInfo(this.actions.wip), false);
   }
 
-  private handlePublishEditTap() {
+  private async handlePublishEditTap() {
     if (this.numberOfThreadsWithUnappliedSuggestions() > 0) {
       assertIsDefined(
         this.confirmPublishEditDialog,
@@ -2151,7 +2160,7 @@
       this.showActionDialog(this.confirmPublishEditDialog);
     } else {
       // Skip confirmation dialog and publish immediately.
-      this.handlePublishEditConfirm();
+      await this.handlePublishEditConfirm();
     }
   }
 
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index b612f73..7184e00 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -517,16 +517,27 @@
     });
   };
 
-  private handlePublishTap = () => {
+  // private but used in test
+  handlePublishTap = async () => {
     const changeNum = this.viewState?.changeNum;
     assertIsDefined(changeNum, 'change number');
 
-    this.saveEdit().then(() => {
+    await this.saveEdit().then(async () => {
       const handleError: ErrorCallback = response => {
         this.showAlert(PUBLISH_FAILED_MSG);
         this.reporting.error('/edit:publish', new Error(response?.statusText));
       };
 
+      if (
+        !(await this.getPluginLoader().jsApiService.handleBeforePublishEdit(
+          this.change as ChangeInfo
+        ))
+      ) {
+        // The event handler should notify with a more specific
+        // message if it blocks publishing.
+        return;
+      }
+
       this.showAlert(PUBLISHING_EDIT_MSG);
 
       // restApiService return undefined if server response with non-200 error
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
index c8b476b..690180c 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
@@ -309,6 +309,7 @@
     test('file modification and publish', async () => {
       const saveSpy = sinon.spy(element, 'saveEdit');
       const alertStub = sinon.stub(element, 'showAlert');
+      const tapSpy = sinon.spy(element, 'handlePublishTap');
       const changeActionsStub = stubRestApi('executeChangeAction').resolves();
       saveFileStub.returns(Promise.resolve({ok: true}));
       element.newContent = newText;
@@ -328,11 +329,12 @@
         query<GrButton>(element, '#save')!.hasAttribute('disabled')
       );
 
-      return saveSpy.lastCall.returnValue.then(() => {
+      return saveSpy.lastCall.returnValue.then(async () => {
         assert.isTrue(saveFileStub.called);
         assert.isFalse(element.saving);
 
         assert.equal(alertStub.getCall(1).args[0], 'All changes saved');
+        await tapSpy.lastCall.returnValue;
         assert.equal(alertStub.getCall(2).args[0], 'Publishing edit...');
 
         assert.isTrue(
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 8417a94..2dca571 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -81,6 +81,19 @@
     return okay;
   }
 
+  async handleBeforePublishEdit(change: ChangeInfo): Promise<boolean> {
+    await this.waitForPluginsToLoad();
+    let okay = true;
+    for (const cb of this._getEventCallbacks(EventType.BEFORE_PUBLISH_EDIT)) {
+      try {
+        okay = (await cb(change)) && okay;
+      } catch (err: unknown) {
+        this.reportError(err, EventType.BEFORE_PUBLISH_EDIT);
+      }
+    }
+    return okay;
+  }
+
   handlePublishEdit(change: ChangeInfo, revision?: RevisionInfo | null) {
     for (const cb of this._getEventCallbacks(EventType.PUBLISH_EDIT)) {
       try {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
index 9f40f7c..7e7ffba 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
@@ -57,6 +57,13 @@
     key: string,
     change?: ParsedChangeInfo
   ): Promise<boolean>;
+  /**
+   * This method is called before publishing a change edit.
+   * It allows plugins to conditionally block edits.
+   * @param change The relevant change.
+   * @return A promise that resolves to true if the action should proceed.
+   */
+  handleBeforePublishEdit(change: ChangeInfo): Promise<boolean>;
   handlePublishEdit(change: ChangeInfo, revision?: RevisionInfo | null): void;
   handleShowChange(detail: ShowChangeDetail): Promise<void>;
   handleShowRevisionActions(detail: ShowRevisionActionsDetail): void;