Add support for abandoning a dead change

Add the ability to abandon a change.  When the abandon button is
clicked, a dialog will pop up for the user to enter an abandon
message.  This message will be added as a comment to the change
and an email will be sent to all reviewers of the change.

Bug: GERRIT-41
Signed-off-by: Brad Larson <bklarson@gmail.com>
Signed-off-by: Shawn O. Pearce <sop@google.com>
diff --git a/src/main/java/com/google/gerrit/Gerrit.gwt.xml b/src/main/java/com/google/gerrit/Gerrit.gwt.xml
index 8d73d5c..2f03f9b 100644
--- a/src/main/java/com/google/gerrit/Gerrit.gwt.xml
+++ b/src/main/java/com/google/gerrit/Gerrit.gwt.xml
@@ -12,7 +12,7 @@
 
 
   <entry-point class='com.google.gerrit.client.Gerrit'/>
-  <stylesheet src='gerrit2.cache.css' />
+  <stylesheet src='gerrit3.cache.css' />
 
   <servlet path='/Gerrit'
            class='com.google.gerrit.server.HostPageServlet'/>
diff --git a/src/main/java/com/google/gerrit/client/changes/AbandonChangeDialog.java b/src/main/java/com/google/gerrit/client/changes/AbandonChangeDialog.java
new file mode 100644
index 0000000..b0d419b
--- /dev/null
+++ b/src/main/java/com/google/gerrit/client/changes/AbandonChangeDialog.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2009 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.client.changes;
+
+import com.google.gerrit.client.patches.PatchUtil;
+import com.google.gerrit.client.reviewdb.PatchSet;
+import com.google.gerrit.client.rpc.GerritCallback;
+import com.google.gerrit.client.ui.SmallHeading;
+import com.google.gwt.user.client.DOM;
+import com.google.gwt.user.client.rpc.AsyncCallback;
+import com.google.gwt.user.client.ui.Button;
+import com.google.gwt.user.client.ui.ClickListener;
+import com.google.gwt.user.client.ui.FlowPanel;
+import com.google.gwt.user.client.ui.TextArea;
+import com.google.gwt.user.client.ui.Widget;
+import com.google.gwtexpui.user.client.AutoCenterDialogBox;
+import com.google.gwtjsonrpc.client.VoidResult;
+
+public class AbandonChangeDialog extends AutoCenterDialogBox {
+
+  private final FlowPanel panel;
+  private final TextArea message;
+  private final Button sendButton;
+  private final Button cancelButton;
+  private final PatchSet.Id psid;
+  private final AsyncCallback<?> appCallback;
+
+  public AbandonChangeDialog(final PatchSet.Id psi,
+      final AsyncCallback<?> callback) {
+    super(/* auto hide */true, /* modal */true);
+
+    psid = psi;
+    appCallback = callback;
+    addStyleName("gerrit-AbandonChangeDialog");
+    setText(Util.C.abandonChangeTitle());
+
+    panel = new FlowPanel();
+    add(panel);
+
+    panel.add(new SmallHeading(Util.C.headingAbandonMessage()));
+
+    final FlowPanel mwrap = new FlowPanel();
+    mwrap.setStyleName("gerrit-AbandonMessage");
+    panel.add(mwrap);
+
+    message = new TextArea();
+    message.setCharacterWidth(60);
+    message.setVisibleLines(10);
+    DOM.setElementPropertyBoolean(message.getElement(), "spellcheck", true);
+    mwrap.add(message);
+
+    final FlowPanel buttonPanel = new FlowPanel();
+    buttonPanel.setStyleName("gerrit-CommentEditor-Buttons");
+    panel.add(buttonPanel);
+
+    sendButton = new Button(Util.C.buttonAbandonChangeSend());
+    sendButton.addClickListener(new ClickListener() {
+      public void onClick(Widget sender) {
+        sendButton.setEnabled(false);
+        PatchUtil.DETAIL_SVC.abandonChange(psid, message.getText().trim(),
+            new GerritCallback<VoidResult>() {
+              public void onSuccess(VoidResult result) {
+                if (appCallback != null) {
+                  appCallback.onSuccess(null);
+                }
+                hide();
+              }
+
+              @Override
+              public void onFailure(Throwable caught) {
+                sendButton.setEnabled(true);
+                super.onFailure(caught);
+              }
+            });
+      }
+    });
+    buttonPanel.add(sendButton);
+
+    cancelButton = new Button(Util.C.buttonAbandonChangeCancel());
+    cancelButton.addClickListener(new ClickListener() {
+      public void onClick(Widget sender) {
+        hide();
+      }
+    });
+    buttonPanel.add(cancelButton);
+  }
+
+  @Override
+  protected void onLoad() {
+    super.onLoad();
+    message.setFocus(true);
+  }
+}
diff --git a/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java b/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
index afc1555..a69d85a 100644
--- a/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
+++ b/src/main/java/com/google/gerrit/client/changes/ChangeConstants.java
@@ -70,6 +70,13 @@
   String patchSetInfoAuthor();
   String patchSetInfoCommitter();
   String patchSetInfoDownload();
+
+  String buttonAbandonChangeBegin();
+  String buttonAbandonChangeSend();
+  String buttonAbandonChangeCancel();
+  String headingAbandonMessage();
+  String abandonChangeTitle();
+
   String buttonPublishCommentsBegin();
   String buttonPublishCommentsSend();
   String buttonPublishCommentsCancel();
diff --git a/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties b/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
index 38546e9..d0662c4 100644
--- a/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
+++ b/src/main/java/com/google/gerrit/client/changes/ChangeConstants.properties
@@ -50,6 +50,13 @@
 patchSetInfoAuthor = Author
 patchSetInfoCommitter = Committer
 patchSetInfoDownload = Download
+
+buttonAbandonChangeBegin = Abandon Change
+buttonAbandonChangeSend = Abandon Change
+buttonAbandonChangeCancel = Cancel
+headingAbandonMessage = Abandon Message:
+abandonChangeTitle = Code Review - Abandon Change
+
 buttonPublishCommentsBegin = Publish Comments
 buttonPublishCommentsSend = Publish Comments
 buttonPublishCommentsCancel = Cancel
diff --git a/src/main/java/com/google/gerrit/client/changes/ChangeDetailServiceImpl.java b/src/main/java/com/google/gerrit/client/changes/ChangeDetailServiceImpl.java
index 959ced2..52263dd 100644
--- a/src/main/java/com/google/gerrit/client/changes/ChangeDetailServiceImpl.java
+++ b/src/main/java/com/google/gerrit/client/changes/ChangeDetailServiceImpl.java
@@ -17,8 +17,11 @@
 import com.google.gerrit.client.data.AccountInfoCacheFactory;
 import com.google.gerrit.client.data.ChangeDetail;
 import com.google.gerrit.client.data.PatchSetDetail;
+import com.google.gerrit.client.data.ProjectCache;
+import com.google.gerrit.client.reviewdb.Account;
 import com.google.gerrit.client.reviewdb.Change;
 import com.google.gerrit.client.reviewdb.PatchSet;
+import com.google.gerrit.client.reviewdb.Project;
 import com.google.gerrit.client.reviewdb.ReviewDb;
 import com.google.gerrit.client.rpc.BaseServiceImplementation;
 import com.google.gerrit.client.rpc.Common;
@@ -32,14 +35,23 @@
       final AsyncCallback<ChangeDetail> callback) {
     run(callback, new Action<ChangeDetail>() {
       public ChangeDetail run(final ReviewDb db) throws OrmException, Failure {
+        final Account.Id me = Common.getAccountId();
         final Change change = db.changes().get(id);
         if (change == null) {
           throw new Failure(new NoSuchEntityException());
         }
+        final PatchSet patch = db.patchSets().get(change.currentPatchSetId());
+        final ProjectCache.Entry projEnt =
+            Common.getProjectCache().get(change.getDest().getParentKey());
+        if (patch == null || projEnt == null) {
+          throw new Failure(new NoSuchEntityException());
+        }
+        final Project proj = projEnt.getProject();
         assertCanRead(change);
 
         final boolean anon;
-        if (Common.getAccountId() == null) {
+        boolean canAbandon = false;
+        if (me == null) {
           // Safe assumption, this wouldn't be allowed if it wasn't.
           //
           anon = true;
@@ -48,9 +60,20 @@
           // we can that doesn't mean the anonymous user could.
           //
           anon = canRead(null, change.getDest().getParentKey());
+
+          // The change owner, current patchset uploader, Gerrit administrator,
+          // and project administrator can mark the change as abandoned.
+          //
+          canAbandon =
+              me.equals(change.getOwner())
+                  || me.equals(patch.getUploader())
+                  || Common.getGroupCache().isAdministrator(me)
+                  || Common.getGroupCache().isInGroup(me,
+                      proj.getOwnerGroupId());
         }
         final ChangeDetail d = new ChangeDetail();
-        d.load(db, new AccountInfoCacheFactory(db), change, anon);
+
+        d.load(db, new AccountInfoCacheFactory(db), change, anon, canAbandon);
         return d;
       }
     });
diff --git a/src/main/java/com/google/gerrit/client/changes/PatchSetPanel.java b/src/main/java/com/google/gerrit/client/changes/PatchSetPanel.java
index 0bf1c3f..4295f27 100644
--- a/src/main/java/com/google/gerrit/client/changes/PatchSetPanel.java
+++ b/src/main/java/com/google/gerrit/client/changes/PatchSetPanel.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.ui.RefreshListener;
 import com.google.gwt.user.client.Window;
+import com.google.gwt.user.client.rpc.AsyncCallback;
 import com.google.gwt.user.client.ui.Button;
 import com.google.gwt.user.client.ui.ClickListener;
 import com.google.gwt.user.client.ui.Composite;
@@ -161,6 +162,9 @@
     if (Gerrit.isSignedIn() && changeDetail.isCurrentPatchSet(detail)) {
       populateCommentAction();
       populateActions(detail);
+      if (changeDetail.canAbandon()) {
+        populateAbandonAction();
+      }
     }
     body.add(patchTable);
   }
@@ -311,6 +315,24 @@
     }
   }
 
+  private void populateAbandonAction() {
+    final Button b = new Button(Util.C.buttonAbandonChangeBegin());
+    b.addClickListener(new ClickListener() {
+      public void onClick(Widget sender) {
+        new AbandonChangeDialog(patchSet.getId(), new AsyncCallback<Object>() {
+          public void onSuccess(Object result) {
+            actionsPanel.remove(b);
+            fireOnSuggestRefresh();
+          }
+
+          public void onFailure(Throwable caught) {
+          }
+        }).center();
+      }
+    });
+    actionsPanel.add(b);
+  }
+
   private void populateCommentAction() {
     final Button b = new Button(Util.C.buttonPublishCommentsBegin());
     b.addClickListener(new ClickListener() {
diff --git a/src/main/java/com/google/gerrit/client/data/ChangeDetail.java b/src/main/java/com/google/gerrit/client/data/ChangeDetail.java
index 036de1e..9677789 100644
--- a/src/main/java/com/google/gerrit/client/data/ChangeDetail.java
+++ b/src/main/java/com/google/gerrit/client/data/ChangeDetail.java
@@ -41,6 +41,7 @@
 public class ChangeDetail {
   protected AccountInfoCache accounts;
   protected boolean allowsAnonymous;
+  protected boolean canAbandon;
   protected Change change;
   protected List<ChangeInfo> dependsOn;
   protected List<ChangeInfo> neededBy;
@@ -56,12 +57,14 @@
   }
 
   public void load(final ReviewDb db, final AccountInfoCacheFactory acc,
-      final Change c, final boolean allowAnon) throws OrmException {
+      final Change c, final boolean allowAnon, final boolean canAbdn)
+      throws OrmException {
     change = c;
     final Account.Id owner = change.getOwner();
     acc.want(owner);
 
     allowsAnonymous = allowAnon;
+    canAbandon = canAbdn;
     patchSets = db.patchSets().byChange(change.getId()).toList();
     messages = db.changeMessages().byChange(change.getId()).toList();
     for (final ChangeMessage m : messages) {
@@ -177,6 +180,10 @@
     return allowsAnonymous;
   }
 
+  public boolean canAbandon() {
+    return canAbandon;
+  }
+
   public Change getChange() {
     return change;
   }
diff --git a/src/main/java/com/google/gerrit/client/patches/PatchDetailService.java b/src/main/java/com/google/gerrit/client/patches/PatchDetailService.java
index 7d00318..04d506b 100644
--- a/src/main/java/com/google/gerrit/client/patches/PatchDetailService.java
+++ b/src/main/java/com/google/gerrit/client/patches/PatchDetailService.java
@@ -54,4 +54,8 @@
   @SignInRequired
   void addReviewers(Change.Id id, List<String> reviewers,
       AsyncCallback<VoidResult> callback);
+
+  @SignInRequired
+  void abandonChange(PatchSet.Id patchSetId, String message,
+      AsyncCallback<VoidResult> callback);
 }
diff --git a/src/main/java/com/google/gerrit/public/gerrit2.cache.css b/src/main/java/com/google/gerrit/public/gerrit3.cache.css
similarity index 96%
rename from src/main/java/com/google/gerrit/public/gerrit2.cache.css
rename to src/main/java/com/google/gerrit/public/gerrit3.cache.css
index 69512fe..66c735e 100644
--- a/src/main/java/com/google/gerrit/public/gerrit2.cache.css
+++ b/src/main/java/com/google/gerrit/public/gerrit3.cache.css
@@ -288,7 +288,7 @@
   border-collapse: separate;
   border-spacing: 0;
 }
-.gerrit-PatchContentTable td {  
+.gerrit-PatchContentTable td {
   font-size: 8pt;
   font-family: monospace;
 }
@@ -777,6 +777,32 @@
 }
 
 
+/** AbandonChangeDialog **/
+
+.gerrit-AbandonChangeDialog .gwt-DisclosurePanel .header td {
+  font-weight: bold;
+  white-space: nowrap;
+}
+
+.gerrit-AbandonChangeDialog .gerrit-SmallHeading {
+  font-size: small;
+  font-weight: bold;
+  white-space: nowrap;
+}
+.gerrit-AbandonChangeDialog .gerrit-AbandonMessage {
+  margin-left: 10px;
+  background: #d4e9a9;
+  padding: 5px 5px 5px 5px;
+}
+.gerrit-AbandonChangeDialog .gerrit-AbandonMessage textarea {
+  font-size: small;
+}
+.gerrit-AbandonChangeDialog .gwt-Hyperlink {
+  white-space: nowrap;
+  font-size: small;
+}
+
+
 /** OpenIdLoginPanel **/
 .gerrit-OpenID-loginform {
   margin-left: 10px;
diff --git a/src/main/java/com/google/gerrit/server/ChangeMail.java b/src/main/java/com/google/gerrit/server/ChangeMail.java
index 4c77fd9..b0515e9 100644
--- a/src/main/java/com/google/gerrit/server/ChangeMail.java
+++ b/src/main/java/com/google/gerrit/server/ChangeMail.java
@@ -295,6 +295,41 @@
     }
   }
 
+  public void sendAbandoned() throws MessagingException {
+    if (begin("abandon")) {
+      final Account a = Common.getAccountCache().get(fromId);
+      if (a == null || a.getFullName() == null || a.getFullName().length() == 0) {
+        body.append("A Gerrit user");
+      } else {
+        body.append(a.getFullName());
+      }
+
+      body.append(" has abandoned a change:\n\n");
+      body.append(change.getChangeId());
+      body.append(" - ");
+      body.append(change.getSubject());
+      body.append("\n\n");
+
+      if (message != null) {
+        body.append(message.getMessage().trim());
+        if (body.length() > 0) {
+          body.append("\n\n");
+        }
+      }
+
+      if (changeUrl() != null) {
+        openFooter();
+        body.append("To view visit ");
+        body.append(changeUrl());
+        body.append("\n");
+      }
+
+      initInReplyToChange();
+      commentTo();
+      send();
+    }
+  }
+
   private void newChangeTo() throws MessagingException {
     add(RecipientType.TO, reviewers);
     add(RecipientType.CC, extraCC);
diff --git a/src/main/java/com/google/gerrit/server/patch/PatchDetailServiceImpl.java b/src/main/java/com/google/gerrit/server/patch/PatchDetailServiceImpl.java
index db835ce..d95bc13 100644
--- a/src/main/java/com/google/gerrit/server/patch/PatchDetailServiceImpl.java
+++ b/src/main/java/com/google/gerrit/server/patch/PatchDetailServiceImpl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.patch;
 
 import com.google.gerrit.client.data.ApprovalType;
+import com.google.gerrit.client.data.ProjectCache;
 import com.google.gerrit.client.data.SideBySidePatchDetail;
 import com.google.gerrit.client.data.UnifiedPatchDetail;
 import com.google.gerrit.client.patches.PatchDetailService;
@@ -28,6 +29,7 @@
 import com.google.gerrit.client.reviewdb.PatchLineComment;
 import com.google.gerrit.client.reviewdb.PatchSet;
 import com.google.gerrit.client.reviewdb.PatchSetInfo;
+import com.google.gerrit.client.reviewdb.Project;
 import com.google.gerrit.client.reviewdb.ReviewDb;
 import com.google.gerrit.client.reviewdb.Account.Id;
 import com.google.gerrit.client.rpc.BaseServiceImplementation;
@@ -355,4 +357,95 @@
     }
     return VoidResult.INSTANCE;
   }
+
+  public void abandonChange(final PatchSet.Id patchSetId, final String message,
+      final AsyncCallback<VoidResult> callback) {
+    run(callback, new Action<VoidResult>() {
+      public VoidResult run(final ReviewDb db) throws OrmException, Failure {
+        final Account.Id me = Common.getAccountId();
+        final Change change = db.changes().get(patchSetId.getParentKey());
+        if (change == null) {
+          throw new Failure(new NoSuchEntityException());
+        }
+        final PatchSet patch = db.patchSets().get(patchSetId);
+        final ProjectCache.Entry projEnt =
+            Common.getProjectCache().get(change.getDest().getParentKey());
+        if (me == null || patch == null || projEnt == null) {
+          throw new Failure(new NoSuchEntityException());
+        }
+        final Project proj = projEnt.getProject();
+
+        if (!me.equals(change.getOwner()) && !me.equals(patch.getUploader())
+            && !Common.getGroupCache().isAdministrator(me)
+            && !Common.getGroupCache().isInGroup(me, proj.getOwnerGroupId())) {
+          // The user doesn't have permission to abandon the change
+          throw new Failure(new NoSuchEntityException());
+        }
+
+        final ChangeMessage cmsg =
+            new ChangeMessage(new ChangeMessage.Key(change.getId(), ChangeUtil
+                .messageUUID(db)), me);
+        final StringBuilder msgBuf =
+            new StringBuilder("Patch Set " + change.currentPatchSetId().get()
+                + ": Abandoned");
+        if (message != null && message.length() > 0) {
+          msgBuf.append("\n\n");
+          msgBuf.append(message);
+        }
+        cmsg.setMessage(msgBuf.toString());
+
+        Boolean dbSuccess = db.run(new OrmRunnable<Boolean, ReviewDb>() {
+          public Boolean run(ReviewDb db, Transaction txn, boolean retry)
+              throws OrmException {
+            return doAbandonChange(message, change, patchSetId, cmsg, db, txn);
+          }
+        });
+
+        if (dbSuccess) {
+          // Email the reviewers
+          try {
+            final ChangeMail cm = new ChangeMail(server, change);
+            cm.setFrom(me);
+            cm.setReviewDb(db);
+            cm.setChangeMessage(cmsg);
+            cm.setHttpServletRequest(GerritJsonServlet.getCurrentCall()
+                .getHttpServletRequest());
+            cm.sendAbandoned();
+          } catch (MessagingException e) {
+            log.error("Cannot send abandon change email for change "
+                + change.getChangeId(), e);
+            throw new Failure(e);
+          }
+        }
+
+        return VoidResult.INSTANCE;
+      }
+    });
+  }
+
+  private Boolean doAbandonChange(final String message, final Change change,
+      final PatchSet.Id psid, final ChangeMessage cm, final ReviewDb db,
+      final Transaction txn) throws OrmException {
+
+    // Check to make sure the change status and current patchset ID haven't
+    // changed while the user was typing an abandon message
+    if (change.getStatus() == Change.Status.NEW
+        && change.currentPatchSetId().equals(psid)) {
+      change.setStatus(Change.Status.ABANDONED);
+      ChangeUtil.updated(change);
+
+      final List<ChangeApproval> approvals =
+          db.changeApprovals().byChange(change.getId()).toList();
+      for (ChangeApproval a : approvals) {
+        a.cache(change);
+      }
+      db.changeApprovals().update(approvals, txn);
+
+      db.changeMessages().insert(Collections.singleton(cm), txn);
+      db.changes().update(Collections.singleton(change), txn);
+      return Boolean.TRUE;
+    }
+
+    return Boolean.FALSE;
+  }
 }