Implement 'Restore Change' feature.

Now it is possible to restore status of abandoned changes to 'NEW'.
It helps in a situation when a change has been abandoned accidently.

Bug: issue 312
Change-Id: Iba61dcf82a9b5ee5b78cd529b041f92a042a9611
This commit is contained in:
Anatol Pomazau 2010-08-04 11:28:50 -07:00
parent 91c1532130
commit 3200245774
15 changed files with 407 additions and 2 deletions

View File

@ -70,6 +70,16 @@ patchset:: link:json.html#patchset[patchset attribute]
abandoner:: link:json.html#account[account attribute]
Change Restored
^^^^^^^^^^^^^^^^
type:: "change-restored"
change:: link:json.html#change[change attribute]
patchset:: link:json.html#patchset[patchset attribute]
restorer:: link:json.html#account[account attribute]
Change Merged
^^^^^^^^^^^^^
type:: "change-merged"

View File

@ -57,6 +57,15 @@ Called whenever a change has been abandoned.
change-abandoned --change <change id> --change-url <change url> --project <project name> --branch <branch> --abandoner <abandoner> --reason <reason>
====
change-restored
~~~~~~~~~~~~~~~~
Called whenever a change has been restored.
====
change-restored --change <change id> --change-url <change url> --project <project name> --branch <branch> --restorer <restorer> --reason <reason>
====
Configuration Settings
----------------------

View File

@ -30,6 +30,7 @@ public class ChangeDetail {
protected AccountInfoCache accounts;
protected boolean allowsAnonymous;
protected boolean canAbandon;
protected boolean canRestore;
protected Change change;
protected boolean starred;
protected List<ChangeInfo> dependsOn;
@ -69,6 +70,14 @@ public class ChangeDetail {
canAbandon = a;
}
public boolean canRestore() {
return canRestore;
}
public void setCanRestore(final boolean a) {
canRestore = a;
}
public Change getChange() {
return change;
}

View File

@ -29,4 +29,8 @@ public interface ChangeManageService extends RemoteJsonService {
@SignInRequired
void abandonChange(PatchSet.Id patchSetId, String message,
AsyncCallback<ChangeDetail> callback);
@SignInRequired
void restoreChange(PatchSet.Id patchSetId, String message,
AsyncCallback<ChangeDetail> callback);
}

View File

@ -111,6 +111,12 @@ public interface ChangeConstants extends Constants {
String headingCoverMessage();
String headingPatchComments();
String buttonRestoreChangeBegin();
String restoreChangeTitle();
String buttonRestoreChangeCancel();
String headingRestoreMessage();
String buttonRestoreChangeSend();
String pagedChangeListPrev();
String pagedChangeListNext();

View File

@ -81,6 +81,12 @@ buttonAbandonChangeCancel = Cancel
headingAbandonMessage = Abandon Message:
abandonChangeTitle = Code Review - Abandon Change
buttonRestoreChangeBegin = Restore Change
restoreChangeTitle = Code Review - Restore Change
buttonRestoreChangeCancel = Cancel
headingRestoreMessage = Restore Message:
buttonRestoreChangeSend = Restore Change
buttonReview = Review
buttonPublishCommentsSend = Publish Comments
buttonPublishSubmitSend = Publish and Submit

View File

@ -418,6 +418,26 @@ class PatchSetComplexDisclosurePanel extends ComplexDisclosurePanel implements O
});
actionsPanel.add(b);
}
if (changeDetail.canRestore()) {
final Button b = new Button(Util.C.buttonRestoreChangeBegin());
b.addClickHandler(new ClickHandler() {
@Override
public void onClick(final ClickEvent event) {
new RestoreChangeDialog(patchSet.getId(),
new AsyncCallback<ChangeDetail>() {
public void onSuccess(ChangeDetail result) {
changeScreen.display(result);
}
public void onFailure(Throwable caught) {
b.setEnabled(true);
}
}).center();
}
});
actionsPanel.add(b);
}
}
private void populateDiffAllActions(final PatchSetDetail detail) {

View File

@ -0,0 +1,133 @@
// 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.Gerrit;
import com.google.gerrit.client.rpc.GerritCallback;
import com.google.gerrit.client.ui.SmallHeading;
import com.google.gerrit.common.data.ChangeDetail;
import com.google.gerrit.reviewdb.PatchSet;
import com.google.gwt.event.dom.client.ClickEvent;
import com.google.gwt.event.dom.client.ClickHandler;
import com.google.gwt.event.logical.shared.CloseEvent;
import com.google.gwt.event.logical.shared.CloseHandler;
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.FlowPanel;
import com.google.gwt.user.client.ui.PopupPanel;
import com.google.gwtexpui.globalkey.client.GlobalKey;
import com.google.gwtexpui.globalkey.client.NpTextArea;
import com.google.gwtexpui.user.client.AutoCenterDialogBox;
public class RestoreChangeDialog extends AutoCenterDialogBox implements CloseHandler<PopupPanel>{
private final FlowPanel panel;
private final NpTextArea message;
private final Button sendButton;
private final Button cancelButton;
private final PatchSet.Id psid;
private final AsyncCallback<ChangeDetail> callback;
private boolean buttonClicked = false;
public RestoreChangeDialog(final PatchSet.Id psi,
final AsyncCallback<ChangeDetail> callback) {
super(/* auto hide */false, /* modal */true);
setGlassEnabled(true);
psid = psi;
this.callback = callback;
addStyleName(Gerrit.RESOURCES.css().abandonChangeDialog());
setText(Util.C.restoreChangeTitle());
panel = new FlowPanel();
add(panel);
panel.add(new SmallHeading(Util.C.headingRestoreMessage()));
final FlowPanel mwrap = new FlowPanel();
mwrap.setStyleName(Gerrit.RESOURCES.css().abandonMessage());
panel.add(mwrap);
message = new NpTextArea();
message.setCharacterWidth(60);
message.setVisibleLines(10);
DOM.setElementPropertyBoolean(message.getElement(), "spellcheck", true);
mwrap.add(message);
final FlowPanel buttonPanel = new FlowPanel();
panel.add(buttonPanel);
sendButton = new Button(Util.C.buttonRestoreChangeSend());
sendButton.addClickHandler(new ClickHandler() {
@Override
public void onClick(final ClickEvent event) {
sendButton.setEnabled(false);
Util.MANAGE_SVC.restoreChange(psid, message.getText().trim(),
new GerritCallback<ChangeDetail>() {
@Override
public void onSuccess(ChangeDetail result) {
buttonClicked = true;
if (callback != null) {
callback.onSuccess(result);
}
hide();
}
@Override
public void onFailure(Throwable caught) {
sendButton.setEnabled(true);
super.onFailure(caught);
}
});
}
});
buttonPanel.add(sendButton);
cancelButton = new Button(Util.C.buttonRestoreChangeCancel());
DOM.setStyleAttribute(cancelButton.getElement(), "marginLeft", "300px");
cancelButton.addClickHandler(new ClickHandler() {
@Override
public void onClick(final ClickEvent event) {
buttonClicked = true;
if (callback != null) {
callback.onFailure(null);
}
hide();
}
});
buttonPanel.add(cancelButton);
addCloseHandler(this);
}
@Override
public void center() {
super.center();
GlobalKey.dialog(this);
message.setFocus(true);
}
@Override
public void onClose(CloseEvent<PopupPanel> event) {
if (!buttonClicked) {
// the dialog was closed without one of the buttons being pressed
// e.g. the user pressed ESC to close the dialog
if (callback != null) {
callback.onFailure(null);
}
}
}
}

View File

@ -101,6 +101,7 @@ public class ChangeDetailFactory extends Handler<ChangeDetail> {
detail.setChange(change);
detail.setAllowsAnonymous(control.forAnonymousUser().isVisible());
detail.setCanAbandon(change.getStatus().isOpen() && control.canAbandon());
detail.setCanRestore(change.getStatus() == Change.Status.ABANDONED && control.canRestore());
detail.setStarred(control.getCurrentUser().getStarredChanges().contains(
changeId));
loadPatchSets();

View File

@ -23,12 +23,15 @@ import com.google.inject.Inject;
class ChangeManageServiceImpl implements ChangeManageService {
private final SubmitAction.Factory submitAction;
private final AbandonChange.Factory abandonChangeFactory;
private final RestoreChange.Factory restoreChangeFactory;
@Inject
ChangeManageServiceImpl(final SubmitAction.Factory patchSetAction,
final AbandonChange.Factory abandonChangeFactory) {
final AbandonChange.Factory abandonChangeFactory,
final RestoreChange.Factory restoreChangeFactory) {
this.submitAction = patchSetAction;
this.abandonChangeFactory = abandonChangeFactory;
this.restoreChangeFactory = restoreChangeFactory;
}
public void submit(final PatchSet.Id patchSetId,
@ -40,4 +43,9 @@ class ChangeManageServiceImpl implements ChangeManageService {
final AsyncCallback<ChangeDetail> callback) {
abandonChangeFactory.create(patchSetId, message).to(callback);
}
public void restoreChange(final PatchSet.Id patchSetId, final String message,
final AsyncCallback<ChangeDetail> callback) {
restoreChangeFactory.create(patchSetId, message).to(callback);
}
}

View File

@ -29,6 +29,7 @@ public class ChangeModule extends RpcServletModule {
@Override
protected void configure() {
factory(AbandonChange.Factory.class);
factory(RestoreChange.Factory.class);
factory(ChangeDetailFactory.Factory.class);
factory(IncludedInDetailFactory.Factory.class);
factory(PatchSetDetailFactory.Factory.class);

View File

@ -0,0 +1,132 @@
// 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.httpd.rpc.changedetail;
import com.google.gerrit.common.ChangeHookRunner;
import com.google.gerrit.common.data.ChangeDetail;
import com.google.gerrit.common.errors.NoSuchEntityException;
import com.google.gerrit.httpd.rpc.Handler;
import com.google.gerrit.reviewdb.*;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.mail.AbandonedSender;
import com.google.gerrit.server.mail.EmailException;
import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
import com.google.gerrit.server.project.ChangeControl;
import com.google.gerrit.server.project.NoSuchChangeException;
import com.google.gwtorm.client.AtomicUpdate;
import com.google.gwtorm.client.OrmException;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
import javax.annotation.Nullable;
import java.util.Collections;
import java.util.List;
class RestoreChange extends Handler<ChangeDetail> {
interface Factory {
RestoreChange create(PatchSet.Id patchSetId, String message);
}
private final ChangeControl.Factory changeControlFactory;
private final ReviewDb db;
private final IdentifiedUser currentUser;
private final AbandonedSender.Factory abandonedSenderFactory;
private final ChangeDetailFactory.Factory changeDetailFactory;
private final PatchSet.Id patchSetId;
@Nullable
private final String message;
private final ChangeHookRunner hooks;
@Inject
RestoreChange(final ChangeControl.Factory changeControlFactory,
final ReviewDb db, final IdentifiedUser currentUser,
final AbandonedSender.Factory abandonedSenderFactory,
final ChangeDetailFactory.Factory changeDetailFactory,
@Assisted final PatchSet.Id patchSetId,
@Assisted @Nullable final String message, final ChangeHookRunner hooks) {
this.changeControlFactory = changeControlFactory;
this.db = db;
this.currentUser = currentUser;
this.abandonedSenderFactory = abandonedSenderFactory;
this.changeDetailFactory = changeDetailFactory;
this.patchSetId = patchSetId;
this.message = message;
this.hooks = hooks;
}
@Override
public ChangeDetail call() throws NoSuchChangeException, OrmException,
EmailException, NoSuchEntityException, PatchSetInfoNotAvailableException {
final Change.Id changeId = patchSetId.getParentKey();
final ChangeControl control = changeControlFactory.validateFor(changeId);
if (!control.canRestore()) {
throw new NoSuchChangeException(changeId);
}
final PatchSet patch = db.patchSets().get(patchSetId);
if (patch == null) {
throw new NoSuchChangeException(changeId);
}
final ChangeMessage cmsg =
new ChangeMessage(new ChangeMessage.Key(changeId, ChangeUtil
.messageUUID(db)), currentUser.getAccountId());
final StringBuilder msgBuf =
new StringBuilder("Patch Set " + patchSetId.get() + ": Restored");
if (message != null && message.length() > 0) {
msgBuf.append("\n\n");
msgBuf.append(message);
}
cmsg.setMessage(msgBuf.toString());
Change change = db.changes().atomicUpdate(changeId, new AtomicUpdate<Change>() {
@Override
public Change update(Change change) {
if (change.getStatus() == Change.Status.ABANDONED
&& change.currentPatchSetId().equals(patchSetId)) {
change.setStatus(Change.Status.NEW);
ChangeUtil.updated(change);
return change;
} else {
return null;
}
}
});
if (change != null) {
db.changeMessages().insert(Collections.singleton(cmsg));
final List<PatchSetApproval> approvals =
db.patchSetApprovals().byChange(changeId).toList();
for (PatchSetApproval a : approvals) {
a.cache(change);
}
db.patchSetApprovals().update(approvals);
// Email the reviewers
final AbandonedSender cm = abandonedSenderFactory.create(change);
cm.setFrom(currentUser.getAccountId());
cm.setChangeMessage(cmsg);
cm.send();
}
hooks.doChangeRestoreHook(change, currentUser.getAccount(), message);
return changeDetailFactory.create(changeId).call();
}
}

View File

@ -30,6 +30,7 @@ import com.google.gerrit.server.events.ApprovalAttribute;
import com.google.gerrit.server.events.ChangeAbandonedEvent;
import com.google.gerrit.server.events.ChangeEvent;
import com.google.gerrit.server.events.ChangeMergedEvent;
import com.google.gerrit.server.events.ChangeRestoreEvent;
import com.google.gerrit.server.events.CommentAddedEvent;
import com.google.gerrit.server.events.EventFactory;
import com.google.gerrit.server.events.PatchSetCreatedEvent;
@ -40,7 +41,6 @@ import com.google.gerrit.server.project.ProjectControl;
import com.google.gerrit.server.project.ProjectState;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.Repository;
import org.slf4j.Logger;
@ -90,6 +90,9 @@ public class ChangeHookRunner {
/** Filename of the change abandoned hook. */
private final File changeAbandonedHook;
/** Filename of the change abandoned hook. */
private final File changeRestoredHook;
/** Repository Manager. */
private final GitRepositoryManager repoManager;
@ -134,6 +137,7 @@ public class ChangeHookRunner {
commentAddedHook = sitePath.resolve(new File(hooksPath, getValue(config, "hooks", "commentAddedHook", "comment-added")).getPath());
changeMergedHook = sitePath.resolve(new File(hooksPath, getValue(config, "hooks", "changeMergedHook", "change-merged")).getPath());
changeAbandonedHook = sitePath.resolve(new File(hooksPath, getValue(config, "hooks", "changeAbandonedHook", "change-abandoned")).getPath());
changeRestoredHook = sitePath.resolve(new File(hooksPath, getValue(config, "hooks", "changeRestoredHook", "change-restored")).getPath());
}
public void addChangeListener(ChangeListener listener, IdentifiedUser user) {
@ -328,6 +332,40 @@ public class ChangeHookRunner {
runHook(getRepo(change), args);
}
/**
* Fire the Change Restored Hook.
*
* @param change The change itself.
* @param account The gerrit user who restored the change.
* @param reason Reason for restoring the change.
*/
public void doChangeRestoreHook(final Change change, final Account account, final String reason) {
final ChangeRestoreEvent event = new ChangeRestoreEvent();
event.change = eventFactory.asChangeAttribute(change);
event.restorer = eventFactory.asAccountAttribute(account);
event.reason = reason;
fireEvent(change, event);
final List<String> args = new ArrayList<String>();
args.add(changeRestoredHook.getAbsolutePath());
args.add("--change");
args.add(event.change.id);
args.add("--change-url");
args.add(event.change.url);
args.add("--project");
args.add(event.change.project);
args.add("--branch");
args.add(event.change.branch);
args.add("--restorer");
args.add(getDisplayName(account));
args.add("--reason");
args.add(reason == null ? "" : reason);
runHook(getRepo(change), args);
}
private void fireEvent(final Change change, final ChangeEvent event) {
for (ChangeListenerHolder holder : listeners.values()) {
if (isVisibleTo(change, holder.user)) {

View File

@ -0,0 +1,23 @@
// Copyright (C) 2010 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;
public class ChangeRestoreEvent extends ChangeEvent {
public final String type = "change-restored";
public ChangeAttribute change;
public PatchSetAttribute patchSet;
public AccountAttribute restorer;
public String reason;
}

View File

@ -159,6 +159,11 @@ public class ChangeControl {
;
}
/** Can this user restore this change? */
public boolean canRestore() {
return canAbandon(); // Anyone who can abandon the change can restore it back
}
public short normalize(ApprovalCategory.Id category, short score) {
return getRefControl().normalize(category, score);
}