Add a change deleted event/listener

The new event is emitted on deletion of a draft change, and deletion of
a regular change.

Plugins may implement the ChangeDeletedListener to perform specific
actions on change deletion.

The event is included in the output of the stream-events ssh command.

Bug: Issue 9711
Change-Id: I37424eafa73703f51072ff8ae6c2546a10ed5c7d
diff --git a/Documentation/cmd-stream-events.txt b/Documentation/cmd-stream-events.txt
index 85fb5e6..1fdf3a8 100644
--- a/Documentation/cmd-stream-events.txt
+++ b/Documentation/cmd-stream-events.txt
@@ -90,6 +90,16 @@
 eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
 created.
 
+== Change Deleted
+
+Sent when a change has been deleted.
+
+type:: "change-deleted"
+
+change:: link:json.html#change[change attribute]
+
+deleter:: link:json.html#account[account attribute]
+
 === Change Merged
 
 Sent when a change has been merged into the git repository.
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java
index 7348c89..9b77411 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/EventRecorder.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.data.RefUpdateAttribute;
+import com.google.gerrit.server.events.ChangeDeletedEvent;
 import com.google.gerrit.server.events.ChangeMergedEvent;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.RefEvent;
@@ -69,6 +70,8 @@
               public void onEvent(Event e) {
                 if (e instanceof ReviewerDeletedEvent) {
                   recordedEvents.put(ReviewerDeletedEvent.TYPE, (ReviewerDeletedEvent) e);
+                } else if (e instanceof ChangeDeletedEvent) {
+                  recordedEvents.put(ChangeDeletedEvent.TYPE, (ChangeDeletedEvent) e);
                 } else if (e instanceof RefEvent) {
                   RefEvent event = (RefEvent) e;
                   String key =
@@ -138,6 +141,21 @@
     return events;
   }
 
+  private ImmutableList<ChangeDeletedEvent> getChangeDeletedEvents(int expectedSize) {
+    String key = ChangeDeletedEvent.TYPE;
+    if (expectedSize == 0) {
+      assertThat(recordedEvents).doesNotContainKey(key);
+      return ImmutableList.of();
+    }
+    assertThat(recordedEvents).containsKey(key);
+    ImmutableList<ChangeDeletedEvent> events =
+        FluentIterable.from(recordedEvents.get(key))
+            .transform(ChangeDeletedEvent.class::cast)
+            .toList();
+    assertThat(events).hasSize(expectedSize);
+    return events;
+  }
+
   public void assertNoRefUpdatedEvents(String project, String branch) throws Exception {
     getRefUpdatedEvents(project, branch, 0);
   }
@@ -197,6 +215,18 @@
     }
   }
 
+  public void assertChangeDeletedEvents(String... expected) {
+    ImmutableList<ChangeDeletedEvent> events = getChangeDeletedEvents(expected.length / 2);
+    int i = 0;
+    for (ChangeDeletedEvent event : events) {
+      String id = event.change.get().id;
+      assertThat(id).isEqualTo(expected[i]);
+      String reviewer = event.deleter.get().email;
+      assertThat(reviewer).isEqualTo(expected[i + 1]);
+      i += 2;
+    }
+  }
+
   public void close() {
     eventListenerRegistration.remove();
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 1bde868..56c85d1 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -437,10 +437,13 @@
   @Test
   public void deleteDraftChange() throws Exception {
     PushOneCommit.Result r = createChange("refs/drafts/master");
-    assertThat(query(r.getChangeId())).hasSize(1);
-    assertThat(info(r.getChangeId()).status).isEqualTo(ChangeStatus.DRAFT);
-    gApi.changes().id(r.getChangeId()).delete();
-    assertThat(query(r.getChangeId())).isEmpty();
+    String changeId = r.getChangeId();
+    assertThat(query(changeId)).hasSize(1);
+    assertThat(info(changeId).status).isEqualTo(ChangeStatus.DRAFT);
+    gApi.changes().id(changeId).delete();
+    assertThat(query(changeId)).isEmpty();
+
+    eventRecorder.assertChangeDeletedEvents(changeId, admin.email);
   }
 
   @Test
@@ -521,6 +524,8 @@
       gApi.changes().id(changeId).delete();
 
       assertThat(query(changeId)).isEmpty();
+
+      eventRecorder.assertChangeDeletedEvents(changeId, deleteAs.email);
     } finally {
       removePermission(Permission.DELETE_OWN_CHANGES, project, "refs/*");
       removePermission(Permission.DELETE_CHANGES, project, "refs/*");
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeDeletedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeDeletedListener.java
new file mode 100644
index 0000000..70014f3
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeDeletedListener.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/** Notified whenever a Change is deleted. */
+@ExtensionPoint
+public interface ChangeDeletedListener {
+  interface Event extends ChangeEvent {}
+
+  void onChangeDeleted(Event event);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java
index 9d819b1..3db995a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.ChangeDeleted;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.BatchUpdateReviewDb;
@@ -66,6 +67,7 @@
   private final StarredChangesUtil starredChangesUtil;
   private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
   private final boolean allowDrafts;
+  private final ChangeDeleted changeDeleted;
 
   private Change.Id id;
 
@@ -74,11 +76,13 @@
       PatchSetUtil psUtil,
       StarredChangesUtil starredChangesUtil,
       DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
-      @GerritServerConfig Config cfg) {
+      @GerritServerConfig Config cfg,
+      ChangeDeleted changeDeleted) {
     this.psUtil = psUtil;
     this.starredChangesUtil = starredChangesUtil;
     this.accountPatchReviewStore = accountPatchReviewStore;
     this.allowDrafts = allowDrafts(cfg);
+    this.changeDeleted = changeDeleted;
   }
 
   @Override
@@ -98,6 +102,7 @@
     deleteChangeElementsFromDb(ctx, id);
 
     ctx.deleteChange();
+    changeDeleted.fire(ctx.getChange(), ctx.getAccount(), ctx.getWhen());
     return true;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
index c7d7f8a..4e0096b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.extensions.events.AgreementSignupListener;
 import com.google.gerrit.extensions.events.AssigneeChangedListener;
 import com.google.gerrit.extensions.events.ChangeAbandonedListener;
+import com.google.gerrit.extensions.events.ChangeDeletedListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.ChangeMergedListener;
 import com.google.gerrit.extensions.events.ChangeRestoredListener;
@@ -310,6 +311,7 @@
     DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
     DynamicSet.setOf(binder(), AssigneeChangedListener.class);
     DynamicSet.setOf(binder(), ChangeAbandonedListener.class);
+    DynamicSet.setOf(binder(), ChangeDeletedListener.class);
     DynamicSet.setOf(binder(), CommentAddedListener.class);
     DynamicSet.setOf(binder(), DraftPublishedListener.class);
     DynamicSet.setOf(binder(), HashtagsEditedListener.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeDeletedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeDeletedEvent.java
new file mode 100644
index 0000000..63142fd
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/ChangeDeletedEvent.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.events;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.data.AccountAttribute;
+
+public class ChangeDeletedEvent extends ChangeEvent {
+  public static final String TYPE = "change-deleted";
+  public Supplier<AccountAttribute> deleter;
+
+  public ChangeDeletedEvent(Change change) {
+    super(TYPE, change);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java
index 448e09f..19470ad 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java
@@ -24,6 +24,7 @@
   static {
     register(AssigneeChangedEvent.TYPE, AssigneeChangedEvent.class);
     register(ChangeAbandonedEvent.TYPE, ChangeAbandonedEvent.class);
+    register(ChangeDeletedEvent.TYPE, ChangeDeletedEvent.class);
     register(ChangeMergedEvent.TYPE, ChangeMergedEvent.class);
     register(ChangeRestoredEvent.TYPE, ChangeRestoredEvent.class);
     register(CommentAddedEvent.TYPE, CommentAddedEvent.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index b76cbef..de6cec1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.events.AssigneeChangedListener;
 import com.google.gerrit.extensions.events.ChangeAbandonedListener;
+import com.google.gerrit.extensions.events.ChangeDeletedListener;
 import com.google.gerrit.extensions.events.ChangeMergedListener;
 import com.google.gerrit.extensions.events.ChangeRestoredListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
@@ -75,6 +76,7 @@
 public class StreamEventsApiListener
     implements AssigneeChangedListener,
         ChangeAbandonedListener,
+        ChangeDeletedListener,
         ChangeMergedListener,
         ChangeRestoredListener,
         CommentAddedListener,
@@ -94,6 +96,7 @@
     protected void configure() {
       DynamicSet.bind(binder(), AssigneeChangedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ChangeAbandonedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), ChangeDeletedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ChangeMergedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ChangeRestoredListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), CommentAddedListener.class).to(StreamEventsApiListener.class);
@@ -497,4 +500,20 @@
       log.error("Failed to dispatch event", e);
     }
   }
+
+  @Override
+  public void onChangeDeleted(ChangeDeletedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      ChangeDeletedEvent event = new ChangeDeletedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.deleter = accountAttributeSupplier(ev.getWho());
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
new file mode 100644
index 0000000..26bc229
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.events.ChangeDeletedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.sql.Timestamp;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class ChangeDeleted {
+  private static final Logger log = LoggerFactory.getLogger(ChangeDeleted.class);
+
+  private final DynamicSet<ChangeDeletedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  ChangeDeleted(DynamicSet<ChangeDeletedListener> listeners, EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, Account deleter, Timestamp when) {
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event = new Event(util.changeInfo(change), util.accountInfo(deleter), when);
+      for (ChangeDeletedListener l : listeners) {
+        try {
+          l.onChangeDeleted(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
+    } catch (OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractChangeEvent implements ChangeDeletedListener.Event {
+    Event(ChangeInfo change, AccountInfo deleter, Timestamp when) {
+      super(change, deleter, when, NotifyHandling.ALL);
+    }
+  }
+}
diff --git a/plugins/hooks b/plugins/hooks
index 08c4186..8b71877 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit 08c418651142c4d98946a050db039d0a892d7ac9
+Subproject commit 8b7187734639e41707f142ca67c7ecf21b9cf3bd