diff options
author | Jukka Jokiniva <jukka.jokiniva@qt.io> | 2018-12-27 13:20:24 +0200 |
---|---|---|
committer | Jukka Jokiniva <jukka.jokiniva@qt.io> | 2019-01-10 09:52:33 +0000 |
commit | b6c336cc2dcc75553d860a18682d893c0505bfd2 (patch) | |
tree | 3ef407c3977b9c609f9fbae9744d42043db91d7a | |
parent | 0e85bdd51a072bacd1a5b3a9e4e52b21491dafd6 (diff) |
Add Defer functionality
Html plugin adds Defer button and dropdown search to UI.
Defer REST API added to java plugin.
Change-Id: I49f70c1bdd1cb8609e4f2a206e937eb4a1a66778
Reviewed-by: Paul Wicking <paul.wicking@qt.io>
Reviewed-by: Frederik Gladhorn <frederik.gladhorn@qt.io>
6 files changed, 589 insertions, 2 deletions
diff --git a/qt-gerrit-ui-plugin/qt-gerrit-ui-plugin.html b/qt-gerrit-ui-plugin/qt-gerrit-ui-plugin.html new file mode 100644 index 0000000..2d42702 --- /dev/null +++ b/qt-gerrit-ui-plugin/qt-gerrit-ui-plugin.html @@ -0,0 +1,190 @@ +// +// Copyright (C) 2018 The Qt Company +// +// This plugin provides UI customization for codereview.qt-project.org +// + +<dom-module id="qt-gerrit-ui-plugin"> + <script> + 'use strict'; + + var BUTTONS = { + 'gerrit-plugin-qt-workflow~defer' : { + header: 'Defer the change?', + action_name: 'defer' + } + }; + + Gerrit.install(plugin => { + + plugin.custom_popup = null; + plugin.custom_popup_promise = null; + plugin.buttons = null; + + function htmlToElement(html) { + var template = document.createElement('template'); + html = html.trim(); // No white space + template.innerHTML = html; + return template.content.firstChild; + } + + // Customize header changes dropdown menu + plugin.hook('header-dropdown-Changes').onAttached(element => { + // this is ugly, but there is no API for this + var ul_elem = element.content.children[1].children[0].children[0].children[0]; + var li_elem; + var link_elem; + + li_elem = htmlToElement(ul_elem.children[1].outerHTML); + link_elem = li_elem.children[0].children[1]; + link_elem.text = 'Deferred'; + link_elem.href = '/q/status:deferred'; + ul_elem.insertBefore(li_elem, ul_elem.children[3]); + }); + + // Customize change view + plugin.on('showchange', function(changeInfo, revisionInfo) { + plugin.ca = plugin.changeActions(); + + // Remove any existing buttons + if (plugin.buttons) { + for (var key in BUTTONS) { + if (BUTTONS.hasOwnProperty(key)) { + if(typeof plugin.buttons[key] !== 'undefined' && plugin.buttons[key] !== null) { + plugin.ca.removeTapListener(plugin.buttons[key], (param) => {} ); + plugin.ca.remove(plugin.buttons[key]); + plugin.buttons[key] = null; + } + } + } + } else plugin.buttons = []; + + // Add buttons based on server response + for (var key in BUTTONS) { + if (BUTTONS.hasOwnProperty(key)) { + var action = plugin.ca.getActionDetails(key); + if (action) { + // hide dropdown action + plugin.ca.setActionHidden(action.__type, action.__key, true); + + // add button + plugin.buttons[key] = plugin.ca.add(action.__type, action.label); + plugin.ca.setTitle(plugin.buttons[key], action.title); + plugin.ca.addTapListener(plugin.buttons[key], buttonEventCallback); + } + } + } + + function buttonEventCallback(event) { + var button_key = event.type.substring(0, event.type.indexOf('-tap')); + var button_action = null; + var button_index; + for (var k in plugin.buttons) { + if (plugin.buttons[k] === button_key) { + button_action = plugin.ca.getActionDetails(k); + button_index = k; + break; + } + } + + if (button_action) { + plugin.popup('qt-gerrit-ui-confirm-dialog').then( (param) => { + plugin.custom_popup_promise = param; + plugin.custom_popup.set('header', BUTTONS[button_index].header); + plugin.custom_popup.set('subject', changeInfo.subject); + plugin.custom_popup.set('action_name', BUTTONS[button_index].action_name); + plugin.custom_popup.set('api_url', button_action.__url); + }).catch( (param) => { console.log('unexpected error: promise failed');}); + } else console.log('unexpected error: no action'); + } + }); + }); + </script> +</dom-module> + +<dom-module id="qt-gerrit-ui-confirm-dialog"> + <template> + <style include="shared-styles"> + #dialog { + min-width: 40em; + } + p { + margin-bottom: 1em; + } + @media screen and (max-width: 50em) { + #dialog { + min-width: inherit; + width: 100%; + } + } + </style> + <gr-dialog + id="dialog" + confirm-label="Continue" + confirm-on-enter + on-cancel="_handleCancelTap" + on-confirm="_handleConfirmTap"> + <div class="header" slot="header">[[header]]</div> + <div class="main" slot="main"> + <p>Ready to [[action_name]] “<strong>[[subject]]</strong>”?</p> + <p class="main" style="color: red;font-weight: bold;">[[errorMessage]]</p> + </div> + </gr-dialog> + </template> + <script> + 'use strict'; + + var qtgerrituiconfirmdialog = Polymer({ + is: 'qt-gerrit-ui-confirm-dialog', + + properties: { + header: { + type: String, + value: '...wait...' + }, + action_name: { + type: String, + value: '' + }, + api_url: { + type: String, + value: '' + }, + subject: { + type: String, + value: '' + }, + errorMessage: { + type: String, + value: '' + } + }, + + attached: function() { + this.plugin.custom_popup = this; + }, + + resetFocus(e) { + this.$.dialog.resetFocus(); + }, + + _handleConfirmTap(e) { + e.preventDefault(); + this.plugin.restApi().post(this.get('api_url'), {}) + .then((ok_resp) => { + this.plugin.custom_popup_promise.close(); + this.plugin.custom_popup_promise = null; + window.location.reload(true); + }).catch((failed_resp) => { + this.set('errorMessage', 'FAILED: ' + failed_resp); + }); + }, + + _handleCancelTap(e) { + e.preventDefault(); + this.plugin.custom_popup_promise.close(); + this.plugin.custom_popup_promise = null; + }, + }); + </script> +</dom-module> diff --git a/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtChangeUpdateOp.java b/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtChangeUpdateOp.java new file mode 100644 index 0000000..491e896 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtChangeUpdateOp.java @@ -0,0 +1,92 @@ +// +// Copyright (C) 2018 The Qt Company +// + +package com.googlesource.gerrit.plugins.qtcodereview; + +import com.google.common.base.Strings; +import com.google.gerrit.common.Nullable; +import com.google.gerrit.extensions.restapi.ResourceConflictException; +import com.google.gerrit.reviewdb.client.Change; +import com.google.gerrit.reviewdb.client.ChangeMessage; +import com.google.gerrit.reviewdb.client.PatchSet; +import com.google.gerrit.server.ChangeMessagesUtil; +import com.google.gerrit.server.notedb.ChangeUpdate; +import com.google.gerrit.server.update.BatchUpdateOp; +import com.google.gerrit.server.update.ChangeContext; +import com.google.gwtorm.server.OrmException; +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; +import java.io.IOException; + +public class QtChangeUpdateOp implements BatchUpdateOp { + + public interface Factory { + QtChangeUpdateOp create(Change.Status newStatus, + @Assisted("defaultMessage") String defaultMessage, + @Assisted("inputMessage") String inputMessage, + @Assisted("tag") String tag); + } + + private final Change.Status newStatus; + private final String defaultMessage; + private final String inputMessage; + private final String tag; + + private Change change; + + private final ChangeMessagesUtil cmUtil; + + + @Inject + QtChangeUpdateOp(ChangeMessagesUtil cmUtil, + @Nullable @Assisted Change.Status newStatus, + @Nullable @Assisted("defaultMessage") String defaultMessage, + @Nullable @Assisted("inputMessage") String inputMessage, + @Nullable @Assisted("tag") String tag) { + this.cmUtil = cmUtil; + this.newStatus = newStatus; + this.defaultMessage = defaultMessage; + this.inputMessage = inputMessage; + this.tag = tag; + } + + public Change getChange() { + return change; + } + + @Override + public boolean updateChange(ChangeContext ctx) throws OrmException, IOException, ResourceConflictException { + boolean updated = false; + change = ctx.getChange(); + PatchSet.Id psId = change.currentPatchSetId(); + ChangeUpdate update = ctx.getUpdate(psId); + + if (newStatus != null) { + change.setStatus(newStatus); + updated = true; + } + + if (updated == true) { + change.setLastUpdatedOn(ctx.getWhen()); + } + + if (defaultMessage != null) { + cmUtil.addChangeMessage(ctx.getDb(), update, newMessage(ctx)); + updated = true; + } + + return updated; + } + + private ChangeMessage newMessage(ChangeContext ctx) { + StringBuilder msg = new StringBuilder(); + msg.append(defaultMessage); + if (!Strings.nullToEmpty(inputMessage).trim().isEmpty()) { + msg.append("\n\n"); + msg.append(inputMessage.trim()); + } + return ChangeMessagesUtil.newMessage(ctx, msg.toString(), tag); + } + +} diff --git a/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtDefer.java b/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtDefer.java new file mode 100644 index 0000000..30b218f --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtDefer.java @@ -0,0 +1,128 @@ +// +// Copyright (C) 2019 The Qt Company +// Modified from https://gerrit.googlesource.com/gerrit/+/refs/heads/stable-2.16/java/com/google/gerrit/server/restapi/change/Abandon.java +// +// Copyright (C) 2012 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.googlesource.gerrit.plugins.qtcodereview; + +import com.google.common.base.Strings; +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.extensions.api.changes.AbandonInput; +import com.google.gerrit.extensions.common.ChangeInfo; +import com.google.gerrit.extensions.restapi.ResourceConflictException; +import com.google.gerrit.extensions.restapi.RestApiException; +import com.google.gerrit.extensions.webui.UiAction; +import com.google.gerrit.reviewdb.client.Change; +import com.google.gerrit.reviewdb.server.ReviewDb; +import com.google.gerrit.server.ChangeUtil; +import com.google.gerrit.server.PatchSetUtil; +import com.google.gerrit.server.change.ChangeJson; +import com.google.gerrit.server.change.ChangeResource; +import com.google.gerrit.server.permissions.ChangePermission; +import com.google.gerrit.server.permissions.PermissionBackendException; +import com.google.gerrit.server.update.BatchUpdate; +import com.google.gerrit.server.update.RetryHelper; +import com.google.gerrit.server.update.RetryingRestModifyView; +import com.google.gerrit.server.update.UpdateException; +import com.google.gerrit.server.util.time.TimeUtil; +import com.google.gwtorm.server.OrmException; +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.Singleton; +import java.io.IOException; + + +@Singleton +class QtDefer extends RetryingRestModifyView<ChangeResource, AbandonInput, ChangeInfo> + implements UiAction<ChangeResource> { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + private final Provider<ReviewDb> dbProvider; + private final ChangeJson.Factory json; + private final PatchSetUtil psUtil; + private final QtChangeUpdateOp.Factory qtUpdateFactory; + + @Inject + QtDefer(Provider<ReviewDb> dbProvider, + ChangeJson.Factory json, + RetryHelper retryHelper, + PatchSetUtil psUtil, + QtChangeUpdateOp.Factory qtUpdateFactory) { + super(retryHelper); + this.dbProvider = dbProvider; + this.json = json; + this.psUtil = psUtil; + this.qtUpdateFactory = qtUpdateFactory; + } + + @Override + protected ChangeInfo applyImpl(BatchUpdate.Factory updateFactory, + ChangeResource rsrc, + AbandonInput input) + throws RestApiException, UpdateException, + OrmException, PermissionBackendException, + IOException { + Change change = rsrc.getChange(); + logger.atInfo().log("qtcodereview: defer %s", rsrc.getChange().toString()); + + // Not allowed to defer if the current patch set is locked. + psUtil.checkPatchSetNotLocked(rsrc.getNotes()); + + // Defer uses same permission as abandon + rsrc.permissions().database(dbProvider).check(ChangePermission.ABANDON); + + if (change.getStatus() != Change.Status.NEW && change.getStatus() != Change.Status.ABANDONED) { + logger.atSevere().log("qtcodereview: defer: change %s status wrong %s", change, change.getStatus()); + throw new ResourceConflictException("change is " + ChangeUtil.status(change)); + } + + QtChangeUpdateOp op = qtUpdateFactory.create(Change.Status.DEFERRED, "Deferred", input.message, null); + try (BatchUpdate u = updateFactory.create(dbProvider.get(), change.getProject(), rsrc.getUser(), TimeUtil.nowTs())) { + u.addOp(rsrc.getId(), op).execute(); + } + + change = op.getChange(); + logger.atInfo().log("qtcodereview: deferred %s", change); + + return json.noOptions().format(change); + } + + @Override + public UiAction.Description getDescription(ChangeResource rsrc) { + UiAction.Description description = new UiAction.Description() + .setLabel("Defer") + .setTitle("Defer the change") + .setVisible(false); + + Change change = rsrc.getChange(); + if (change.getStatus() != Change.Status.NEW && change.getStatus() != Change.Status.ABANDONED) { + return description; + } + + try { + if (psUtil.isPatchSetLocked(rsrc.getNotes())) { + return description; + } + } catch (OrmException | IOException e) { + logger.atSevere().withCause(e).log("Failed to check if the current patch set of change %s is locked", change.getId()); + return description; + } + + return description.setVisible(rsrc.permissions().testOrFalse(ChangePermission.ABANDON)); + } + +} diff --git a/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtModule.java b/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtModule.java index 5cfba4a..0753356 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtModule.java +++ b/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtModule.java @@ -4,6 +4,8 @@ package com.googlesource.gerrit.plugins.qtcodereview; +import static com.google.gerrit.server.change.ChangeResource.CHANGE_KIND; + import com.google.common.flogger.FluentLogger; import com.google.gerrit.extensions.config.FactoryModule; import com.google.gerrit.extensions.restapi.RestApiModule; @@ -18,10 +20,13 @@ public class QtModule extends FactoryModule { @Override protected void configure() { + factory(QtChangeUpdateOp.Factory.class); + install( new RestApiModule() { @Override protected void configure() { + post(CHANGE_KIND, "defer").to(QtDefer.class); } } ); diff --git a/src/test/java/com/googlesource/gerrit/plugins/qtcodereview/QtCodeReviewIT.java b/src/test/java/com/googlesource/gerrit/plugins/qtcodereview/QtCodeReviewIT.java index 2aa798a..fd0d42a 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/qtcodereview/QtCodeReviewIT.java +++ b/src/test/java/com/googlesource/gerrit/plugins/qtcodereview/QtCodeReviewIT.java @@ -2,24 +2,72 @@ package com.googlesource.gerrit.plugins.qtcodereview; +import static com.google.common.truth.Truth.assertThat; + import com.google.gerrit.acceptance.LightweightPluginDaemonTest; +import com.google.gerrit.acceptance.PushOneCommit; +import com.google.gerrit.acceptance.RestResponse; import com.google.gerrit.acceptance.TestPlugin; -import com.google.gerrit.acceptance.UseSsh; + +import com.google.gerrit.reviewdb.client.Change; + +import org.eclipse.jgit.revwalk.RevCommit; import org.junit.Test; + @TestPlugin( name = "gerrit-plugin-qt-workflow", sysModule = "com.googlesource.gerrit.plugins.qtcodereview.QtModule", sshModule = "com.googlesource.gerrit.plugins.qtcodereview.QtSshModule" ) -@UseSsh public class QtCodeReviewIT extends LightweightPluginDaemonTest { + protected static final String R_HEADS = "refs/heads/"; + protected static final String R_STAGING = "refs/staging/"; + protected static final String R_PUSH = "refs/for/"; + @Test public void dummyTest() { } + +// Helper functions + + protected void QtDefer(PushOneCommit.Result c) throws Exception { + RestResponse response = call_REST_API_Defer(c.getChangeId()); + response.assertOK(); + } + + protected RestResponse call_REST_API_Defer(String changeId) throws Exception { + String url = "/changes/"+changeId+"/gerrit-plugin-qt-workflow~defer"; + RestResponse response = userRestSession.post(url); + return response; + } + + protected PushOneCommit.Result pushCommit(String branch, + String message, + String file, + String content) + throws Exception { + String pushRef = R_PUSH + branch; + PushOneCommit.Result c = createUserChange(pushRef, message, file, content); + Change change = c.getChange().change(); + assertThat(change.getStatus()).isEqualTo(Change.Status.NEW); + return c; + } + + protected PushOneCommit.Result createUserChange(String ref, String message, String file, String content) throws Exception { + PushOneCommit push = pushFactory.create(db, user.getIdent(), testRepo, message, file, content); + PushOneCommit.Result result = push.to(ref); + result.assertOkStatus(); + return result; + } + + protected void assertRefUpdatedEvents(String refName, RevCommit ... expected) throws Exception { + eventRecorder.assertRefUpdatedEvents(project.get(), refName, expected); + } + } diff --git a/src/test/java/com/googlesource/gerrit/plugins/qtcodereview/QtDeferIT.java b/src/test/java/com/googlesource/gerrit/plugins/qtcodereview/QtDeferIT.java new file mode 100644 index 0000000..b2b46b8 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/qtcodereview/QtDeferIT.java @@ -0,0 +1,124 @@ +// Copyright (C) 2018 The Qt Company + +package com.googlesource.gerrit.plugins.qtcodereview; + +import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.gerrit.acceptance.LightweightPluginDaemonTest; +import com.google.gerrit.acceptance.PushOneCommit; +import com.google.gerrit.acceptance.RestResponse; +import com.google.gerrit.acceptance.TestPlugin; +import com.google.gerrit.acceptance.UseSsh; + +import com.google.gerrit.common.data.Permission; +import com.google.gerrit.extensions.api.changes.AbandonInput; +import com.google.gerrit.reviewdb.client.Change; + +import org.eclipse.jgit.revwalk.RevCommit; + +import org.junit.Before; +import org.junit.Test; + +import org.apache.http.HttpStatus; + +@TestPlugin( + name = "gerrit-plugin-qt-workflow", + sysModule = "com.googlesource.gerrit.plugins.qtcodereview.QtModule", + sshModule = "com.googlesource.gerrit.plugins.qtcodereview.QtSshModule" +) + +@UseSsh +public class QtDeferIT extends QtCodeReviewIT { + + @Before + public void SetDefaultPermissions() throws Exception { + grant(project, "refs/heads/master", Permission.ABANDON, false, REGISTERED_USERS); + } + + @Test + public void singleChange_Defer() throws Exception { + RevCommit initialHead = getRemoteHead(); + PushOneCommit.Result c = pushCommit("master", "commitmsg1","file1", "content1"); + + RevCommit updatedHead = qtDefer(c, initialHead); + } + + @Test + public void singleChange_Defer_With_Input_Message() throws Exception { + PushOneCommit.Result c = pushCommit("master", "commitmsg1","file1", "content1"); + approve(c.getChangeId()); + String changeId = c.getChangeId(); + + AbandonInput abandonInput = new AbandonInput(); + abandonInput.message = "myabandonednote"; + + String url = "/changes/"+changeId+"/gerrit-plugin-qt-workflow~defer"; + RestResponse response = userRestSession.post(url, abandonInput); + response.assertOK(); + Change change = c.getChange().change(); + assertThat(change.getStatus()).isEqualTo(Change.Status.DEFERRED); + } + + @Test + public void errorDefer_No_Permission() throws Exception { + deny(project, "refs/heads/master", Permission.ABANDON, REGISTERED_USERS); + + PushOneCommit.Result c = pushCommit("master", "commitmsg1", "file1", "content1"); + RestResponse response = qtDeferExpectFail(c, HttpStatus.SC_FORBIDDEN); + assertThat(response.getEntityContent()).isEqualTo("abandon not permitted"); + + grant(project, "refs/heads/master", Permission.ABANDON, false, REGISTERED_USERS); + } + + @Test + public void errorDefer_Wrong_Status() throws Exception { + RevCommit initialHead = getRemoteHead(); + PushOneCommit.Result c = pushCommit("master", "commitmsg1", "file1", "content1"); + merge(c); + + RestResponse response = qtDeferExpectFail(c, HttpStatus.SC_CONFLICT); + assertThat(response.getEntityContent()).isEqualTo("change is merged"); + } + + + private RevCommit qtDefer(PushOneCommit.Result c, + RevCommit initialHead) + throws Exception { + String masterRef = R_HEADS + "master"; + String stagingRef = R_STAGING + "master"; + + RestResponse response = call_REST_API_Defer(c.getChangeId()); + response.assertOK(); + + RevCommit masterHead = getRemoteHead(project, masterRef); + assertThat(masterHead.getId()).isEqualTo(initialHead.getId()); // master is not updated + + RevCommit stagingHead = getRemoteHead(project, stagingRef); + if (stagingHead != null) assertThat(stagingHead.getId()).isEqualTo(initialHead.getId()); // staging is not updated + + assertRefUpdatedEvents(masterRef); // no events + assertRefUpdatedEvents(stagingRef); // no events + + Change change = c.getChange().change(); + assertThat(change.getStatus()).isEqualTo(Change.Status.DEFERRED); + + return masterHead; + } + + private RestResponse qtDeferExpectFail(PushOneCommit.Result c, + int expectedStatus) + throws Exception { + RestResponse response = call_REST_API_Defer(c.getChangeId()); + response.assertStatus(expectedStatus); + + Change change = c.getChange().change(); + assertThat(change.getStatus()).isNotEqualTo(Change.Status.DEFERRED); + + return response; + } + + + +} |