From 765d16cf31f6068bd83c5c98e7932232b84f8616 Mon Sep 17 00:00:00 2001 From: Jukka Jokiniva Date: Wed, 2 Jan 2019 15:08:21 +0200 Subject: Add Stage functionality Stage button and search added to UI. Also corresponding REST API added. Fixes: QTBI-1402 Change-Id: I49e6999813aa48d142b885a1713f54b8d559286c Reviewed-by: Frederik Gladhorn --- qt-gerrit-ui-plugin/qt-gerrit-ui-plugin.html | 13 +- .../gerrit/plugins/qtcodereview/QtAbandon.java | 5 +- .../plugins/qtcodereview/QtChangeUpdateOp.java | 114 +++++++- .../plugins/qtcodereview/QtCherryPickPatch.java | 198 ++++++++++++++ .../gerrit/plugins/qtcodereview/QtDefer.java | 2 +- .../gerrit/plugins/qtcodereview/QtModule.java | 2 + .../gerrit/plugins/qtcodereview/QtReOpen.java | 2 +- .../gerrit/plugins/qtcodereview/QtStage.java | 269 +++++++++++++++++++ .../gerrit/plugins/qtcodereview/QtUtil.java | 168 ++++++++++++ .../plugins/qtcodereview/QtCodeReviewIT.java | 130 ++++++++- .../gerrit/plugins/qtcodereview/QtStageIT.java | 294 +++++++++++++++++++++ 11 files changed, 1183 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtCherryPickPatch.java create mode 100644 src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtStage.java create mode 100644 src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtUtil.java create mode 100644 src/test/java/com/googlesource/gerrit/plugins/qtcodereview/QtStageIT.java diff --git a/qt-gerrit-ui-plugin/qt-gerrit-ui-plugin.html b/qt-gerrit-ui-plugin/qt-gerrit-ui-plugin.html index 03480b0..66c6e49 100644 --- a/qt-gerrit-ui-plugin/qt-gerrit-ui-plugin.html +++ b/qt-gerrit-ui-plugin/qt-gerrit-ui-plugin.html @@ -20,6 +20,10 @@ 'gerrit-plugin-qt-workflow~reopen' : { header: 'Reopen the change?', action_name: 'reopen' + }, + 'gerrit-plugin-qt-workflow~stage' : { + header: 'Submit to staging?', + action_name: 'stage' } }; @@ -72,11 +76,18 @@ 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 = 'Staged'; + link_elem.href = '/q/status:staged'; + ul_elem.insertBefore(li_elem, ul_elem.children[2]); + + 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]); + ul_elem.insertBefore(li_elem, ul_elem.children[4]); }); // Customize change view diff --git a/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtAbandon.java b/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtAbandon.java index b0dafe6..0d37198 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtAbandon.java +++ b/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtAbandon.java @@ -1,5 +1,5 @@ // -// Copyright (C) 2018 The Qt Company +// Copyright (C) 2019 The Qt Company // package com.googlesource.gerrit.plugins.qtcodereview; @@ -81,7 +81,8 @@ public class QtAbandon extends RetryingRestModifyView byKey = new HashMap<>(); + for (PatchSetApproval psa : approvalsUtil.byPatchSet(ctx.getDb(), + ctx.getNotes(), + psId, + ctx.getRevWalk(), + ctx.getRepoView().getConfig())) { + byKey.put(psa.getKey(), psa); + } + + submitter = ApprovalsUtil.newApproval(psId, ctx.getUser(), LabelId.legacySubmit(), 1, ctx.getWhen()); + byKey.put(submitter.getKey(), submitter); + + LabelNormalizer.Result normalized = labelNormalizer.normalize(ctx.getNotes(), byKey.values()); + update.putApproval(submitter.getLabel(), submitter.getValue()); + saveApprovals(normalized, ctx, update, false); + return normalized; + } + + private void saveApprovals(LabelNormalizer.Result normalized, + ChangeContext ctx, + ChangeUpdate update, + boolean includeUnchanged) + throws OrmException { + PatchSet.Id psId = update.getPatchSetId(); + ctx.getDb().patchSetApprovals().upsert(convertPatchSet(normalized.getNormalized(), psId)); + ctx.getDb().patchSetApprovals().upsert(zero(convertPatchSet(normalized.deleted(), psId))); + for (PatchSetApproval psa : normalized.updated()) { + update.putApprovalFor(psa.getAccountId(), psa.getLabel(), psa.getValue()); + } + for (PatchSetApproval psa : normalized.deleted()) { + update.removeApprovalFor(psa.getAccountId(), psa.getLabel()); + } + + for (PatchSetApproval psa : normalized.unchanged()) { + if (includeUnchanged || psa.isLegacySubmit()) { + logger.atFine().log("Adding submit label %s", psa); + update.putApprovalFor(psa.getAccountId(), psa.getLabel(), psa.getValue()); + } + } + } + + private Function convertPatchSet(final PatchSet.Id psId) { + return psa -> { + if (psa.getPatchSetId().equals(psId)) { + return psa; + } + return new PatchSetApproval(psId, psa); + }; + } + + private Iterable convertPatchSet(Iterable approvals, PatchSet.Id psId) { + return Iterables.transform(approvals, convertPatchSet(psId)); + } + + private Iterable zero(Iterable approvals) { + return Iterables.transform( + approvals, + a -> { + PatchSetApproval copy = new PatchSetApproval(a.getPatchSetId(), a); + copy.setValue((short) 0); + return copy; + } + ); + } + } diff --git a/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtCherryPickPatch.java b/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtCherryPickPatch.java new file mode 100644 index 0000000..73eb7ce --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtCherryPickPatch.java @@ -0,0 +1,198 @@ +// +// Copyright (C) 2019 The Qt Company +// + +package com.googlesource.gerrit.plugins.qtcodereview; + +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.extensions.api.changes.NotifyHandling; +import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.extensions.restapi.MergeConflictException; +import com.google.gerrit.extensions.restapi.MethodNotAllowedException; +import com.google.gerrit.extensions.restapi.RestApiException; +import com.google.gerrit.reviewdb.client.Change; +import com.google.gerrit.reviewdb.client.PatchSet; +import com.google.gerrit.reviewdb.client.Project; +import com.google.gerrit.reviewdb.server.ReviewDb; +import com.google.gerrit.server.ChangeUtil; +import com.google.gerrit.server.IdentifiedUser; +import com.google.gerrit.server.change.PatchSetInserter; +import com.google.gerrit.server.git.CodeReviewCommit; +import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk; +import com.google.gerrit.server.git.GitRepositoryManager; +import com.google.gerrit.server.git.MergeUtil; +import com.google.gerrit.server.notedb.ChangeNotes; +import com.google.gerrit.server.project.NoSuchProjectException; +import com.google.gerrit.server.project.NoSuchRefException; +import com.google.gerrit.server.project.ProjectCache; +import com.google.gerrit.server.project.ProjectState; +import com.google.gerrit.server.query.change.ChangeData; +import com.google.gerrit.server.submit.IntegrationException; +import com.google.gerrit.server.submit.MergeIdenticalTreeException; +import com.google.gerrit.server.update.BatchUpdate; +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; +import java.sql.Timestamp; +import java.util.Arrays; +import java.util.List; +import org.eclipse.jgit.errors.ConfigInvalidException; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectInserter; +import org.eclipse.jgit.lib.ObjectReader; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; + +@Singleton +public class QtCherryPickPatch { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + private final Provider dbProvider; + private final BatchUpdate.Factory batchUpdateFactory; + private final GitRepositoryManager gitManager; + private final Provider user; + private final PatchSetInserter.Factory patchSetInserterFactory; + private final MergeUtil.Factory mergeUtilFactory; + private final ProjectCache projectCache; + private final QtChangeUpdateOp.Factory qtUpdateFactory; + + @Inject + QtCherryPickPatch(Provider dbProvider, + BatchUpdate.Factory batchUpdateFactory, + GitRepositoryManager gitManager, + Provider user, + PatchSetInserter.Factory patchSetInserterFactory, + MergeUtil.Factory mergeUtilFactory, + ProjectCache projectCache, + QtChangeUpdateOp.Factory qtUpdateFactory) { + this.dbProvider = dbProvider; + this.batchUpdateFactory = batchUpdateFactory; + this.gitManager = gitManager; + this.user = user; + this.patchSetInserterFactory = patchSetInserterFactory; + this.mergeUtilFactory = mergeUtilFactory; + this.projectCache = projectCache; + this.qtUpdateFactory = qtUpdateFactory; + } + + public CodeReviewCommit cherryPickPatch(ChangeData changeData, + Project.NameKey project, + ObjectId sourceId, + ObjectId destId, + boolean allowFastForward, + Change.Status newStatus, + String defaultMessage, + String inputMessage, + String tag) + throws IntegrationException { + + IdentifiedUser identifiedUser = user.get(); + try (Repository git = gitManager.openRepository(project); + ObjectInserter oi = git.newObjectInserter(); + ObjectReader reader = oi.newReader(); + CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(reader)) { + + if (!git.hasObject(sourceId)) throw new NoSuchRefException("Invalid source objectId: " + sourceId); + if (!git.hasObject(destId)) throw new NoSuchRefException("Invalid destination objectid: " + destId); + + RevCommit baseCommit = revWalk.parseCommit(destId); + CodeReviewCommit commitToCherryPick = revWalk.parseCommit(sourceId); + + List parents = Arrays.asList(commitToCherryPick.getParents()); + if (allowFastForward == true && parents.contains(baseCommit) && commitToCherryPick.getParentCount() < 2) { + logger.atInfo().log("qtcodereview: cherrypick fast forward %s on top of %s", sourceId, destId); + return commitToCherryPick; + } + + Timestamp now = TimeUtil.nowTs(); + PersonIdent committerIdent = commitToCherryPick.getCommitterIdent(); + + commitToCherryPick.setPatchsetId(changeData.currentPatchSet().getId()); + commitToCherryPick.setNotes(changeData.notes()); + + CodeReviewCommit cherryPickCommit; + boolean mergeCommit = false; + + ProjectState projectState = projectCache.checkedGet(project); + if (projectState == null) throw new NoSuchProjectException(project); + + MergeUtil mergeUtil = mergeUtilFactory.create(projectState, true); + if (commitToCherryPick.getParentCount() > 1) { + // Merge commit cannot be cherrypicked + logger.atInfo().log("qtcodereview: merge commit detected %s", commitToCherryPick); + mergeCommit = true; + RevCommit commit = QtUtil.merge(committerIdent, + git, oi, + revWalk, + commitToCherryPick, + baseCommit, + true /* merge always */); + cherryPickCommit = revWalk.parseCommit(commit); + } else { + String commitMessage = mergeUtil.createCommitMessageOnSubmit(commitToCherryPick, baseCommit); + commitMessage += " "; // This ensures unique SHA1 is generated, otherwise old is reused + cherryPickCommit = mergeUtil.createCherryPickFromCommit(oi, + git.getConfig(), + baseCommit, + commitToCherryPick, + committerIdent, + commitMessage, + revWalk, + 0, + true, // ignoreIdenticalTree + false); // allowConflicts + } + + boolean patchSetNotChanged = cherryPickCommit.equals(commitToCherryPick); + if (!patchSetNotChanged) { + logger.atInfo().log("qtcodereview: new patch %s -> %s", commitToCherryPick, cherryPickCommit); + oi.flush(); + } + BatchUpdate bu = batchUpdateFactory.create(dbProvider.get(), project, identifiedUser, now); + bu.setRepository(git, revWalk, oi); + if (!patchSetNotChanged && !mergeCommit) { + Change.Id changeId = insertPatchSet(bu, git, changeData.notes(), cherryPickCommit); + bu.addOp(changeData.getId(), qtUpdateFactory.create(newStatus, + defaultMessage, + inputMessage, + tag, + commitToCherryPick)); + logger.atInfo().log("qtcodereview: cherrypick new patch %s for %s", cherryPickCommit.toObjectId(), changeId); + } else { + bu.addOp(changeData.getId(), qtUpdateFactory.create(newStatus, + defaultMessage, + inputMessage, + tag, + null)); + } + + bu.execute(); + logger.atInfo().log("qtcodereview: cherrypick done %s", changeData.getId()); + return cherryPickCommit; + } catch (Exception e) { + throw new IntegrationException("Cherry pick failed: " + e.getMessage()); + } + } + + private Change.Id insertPatchSet(BatchUpdate bu, + Repository git, + ChangeNotes destNotes, + CodeReviewCommit cherryPickCommit) + throws IOException, OrmException, BadRequestException, ConfigInvalidException { + Change destChange = destNotes.getChange(); + PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId()); + PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, cherryPickCommit); + inserter.setNotify(NotifyHandling.NONE) + .setAllowClosed(true); + // .setCopyApprovals(true) doesn't work, so copying done in QtChangeUpdateOp + bu.addOp(destChange.getId(), inserter); + return destChange.getId(); + } + +} diff --git a/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtDefer.java b/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtDefer.java index 30b218f..afd1771 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtDefer.java +++ b/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtDefer.java @@ -90,7 +90,7 @@ class QtDefer extends RetryingRestModifyView, + UiAction { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + private static final String DEFAULT_TOOLTIP = "Stage patch set ${patchSet} into ${branch}"; + + public static class Output { + transient Change change; + private Output(Change c) { + change = c; + } + } + + private final Provider dbProvider; + private final GitRepositoryManager repoManager; + private final PermissionBackend permissionBackend; + private final ChangeData.Factory changeDataFactory; + private final ProjectCache projectCache; + private final GitReferenceUpdated referenceUpdated; + private final QtCherryPickPatch qtCherryPickPatch; + private final QtUtil qtUtil; + + private final AccountResolver accountResolver; + private final String label; + private final ParameterizedString titlePattern; + + private Change change; + private Project.NameKey projectKey; + private Branch.NameKey destBranchKey; + private Branch.NameKey stagingBranchKey; + + @Inject + QtStage(Provider dbProvider, + GitRepositoryManager repoManager, + PermissionBackend permissionBackend, + ChangeData.Factory changeDataFactory, + AccountResolver accountResolver, + @GerritServerConfig Config cfg, + ProjectCache projectCache, + GitReferenceUpdated referenceUpdated, + QtCherryPickPatch qtCherryPickPatch, + QtUtil qtUtil) { + + this.dbProvider = dbProvider; + this.repoManager = repoManager; + this.permissionBackend = permissionBackend; + this.changeDataFactory = changeDataFactory; + this.accountResolver = accountResolver; + this.label = "Stage"; + this.titlePattern = + new ParameterizedString( + MoreObjects.firstNonNull( + cfg.getString("change", null, "stageTooltip"), DEFAULT_TOOLTIP)); + this.projectCache = projectCache; + this.referenceUpdated = referenceUpdated; + this.qtCherryPickPatch = qtCherryPickPatch; + this.qtUtil = qtUtil; + } + + @Override + public Output apply(RevisionResource rsrc, SubmitInput input) + throws RestApiException, RepositoryNotFoundException, IOException, OrmException, + PermissionBackendException, UpdateException, ConfigInvalidException { + + logger.atInfo().log("qtcodereview: submit %s to staging", rsrc.getChange().toString()); + + IdentifiedUser submitter = rsrc.getUser().asIdentifiedUser(); + change = rsrc.getChange(); + projectKey = rsrc.getProject(); + destBranchKey = change.getDest(); + stagingBranchKey = QtUtil.getStagingBranch(destBranchKey); + + rsrc.permissions().check(ChangePermission.QT_STAGE); + + projectCache.checkedGet(rsrc.getProject()).checkStatePermitsWrite(); + + return new Output(changeToStaging(rsrc, submitter, input)); + } + + private Change changeToStaging(RevisionResource rsrc, IdentifiedUser submitter, SubmitInput input) + throws OrmException, RestApiException, IOException, UpdateException, ConfigInvalidException, + PermissionBackendException { + logger.atInfo().log("qtcodereview: changeToStaging starts"); + + if (change.getStatus() != Change.Status.NEW) { + logger.atSevere().log("qtcodereview: stage: change %s status wrong: %s", + change, change.getStatus()); + throw new ResourceConflictException("change is " + change.getStatus()); + } else if (!ProjectUtil.branchExists(repoManager, change.getDest())) { + logger.atSevere().log("qtcodereview: stage: change %s destination branch \"%s\" not found", + change, change.getDest().get()); + throw new ResourceConflictException(String.format("destination branch \"%s\" not found.", + change.getDest().get())); + } else if (!rsrc.getPatchSet().getId().equals(change.currentPatchSetId())) { + logger.atSevere().log("qtcodereview: stage: change %s revision %s is not current revision", + change, rsrc.getPatchSet().getRevision().get()); + throw new ResourceConflictException(String.format("revision %s is not current revision", + rsrc.getPatchSet().getRevision().get())); + } + + Repository git = null; + ObjectId destId = null; + ObjectId sourceId = null; + ChangeData changeData; + + try { + git = repoManager.openRepository(projectKey); + // Check if staging branch exists. Create the staging branch if it does not exist. + if (!ProjectUtil.branchExists(repoManager, stagingBranchKey)) { + Result result = QtUtil.createStagingBranch(git, destBranchKey); + if (result == null) throw new NoSuchRefException("Cannot create staging ref: " + stagingBranchKey.get()); + } + destId = git.resolve(stagingBranchKey.get()); + if (destId == null) throw new NoSuchRefException("Invalid Revision: " + stagingBranchKey.get()); + + sourceId = git.resolve(rsrc.getPatchSet().getRevision().get()); + if (sourceId == null) throw new NoSuchRefException("Invalid Revision: " + rsrc.getPatchSet().getRevision().get()); + + changeData = changeDataFactory.create(dbProvider.get(), change); + MergeOp.checkSubmitRule(changeData, false); + + CodeReviewCommit commit = qtCherryPickPatch.cherryPickPatch(changeData, + projectKey, + sourceId, + destId, + false, // allowFastForward + Change.Status.STAGED, + "Staged for CI", // defaultMessage + null, // inputMessage + null // tag + ); + Result result = qtUtil.updateRef(git, stagingBranchKey.get(), commit.toObjectId(), false); + referenceUpdated.fire(projectKey, stagingBranchKey.get(), destId, commit.toObjectId(), submitter.state()); + + } catch (IntegrationException e) { + logger.atInfo().log("qtcodereview: stage merge error %s", e); + throw new ResourceConflictException("merge error " + e); + } catch (NoSuchRefException e) { + logger.atSevere().log("qtcodereview: stage error %s", e); + throw new ResourceConflictException("resource error " + e); + } finally { + if (git != null) { + git.close(); + } + } + + change = changeData.reloadChange(); + switch (change.getStatus()) { + case STAGED: + logger.atInfo().log("qtcodereview: changeToStaging %s added to %s", change, stagingBranchKey); + return change; // this doesn't return data to client, if needed use ChangeJson to convert it + case NEW: + throw new RestApiException("change unexpectedly had status " + change.getStatus() + " after submit attempt"); + case ABANDONED: + default: + throw new ResourceConflictException("change is " + change.getStatus()); + } + + } + + @Override + public UiAction.Description getDescription(RevisionResource resource) { + Change change = resource.getChange(); + if (!change.getStatus().isOpen() + || change.isWorkInProgress() + || !resource.isCurrent() + || !resource.permissions().testOrFalse(ChangePermission.QT_STAGE)) { + return null; // submit not visible + } + try { + if (!projectCache.checkedGet(resource.getProject()).statePermitsWrite()) { + return null; // stage not visible + } + } catch (IOException e) { + logger.atSevere().withCause(e).log("Error checking if change is submittable"); + throw new OrmRuntimeException("Could not determine problems for the change", e); + } + + ReviewDb db = dbProvider.get(); + ChangeData cd = changeDataFactory.create(db, resource.getNotes()); + try { + MergeOp.checkSubmitRule(cd, false); + } catch (ResourceConflictException e) { + return null; // stage not visible + } catch (OrmException e) { + logger.atSevere().withCause(e).log("Error checking if change is submittable"); + throw new OrmRuntimeException("Could not determine problems for the change", e); + } + + Boolean enabled; + try { + enabled = cd.isMergeable(); + } catch (OrmException e) { + throw new OrmRuntimeException("Could not determine mergeability", e); + } + + RevId revId = resource.getPatchSet().getRevision(); + Map params = + ImmutableMap.of( + "patchSet", String.valueOf(resource.getPatchSet().getPatchSetId()), + "branch", change.getDest().getShortName(), + "commit", ObjectId.fromString(revId.get()).abbreviate(7).name() ); + return new UiAction.Description() + .setLabel( label) + .setTitle(Strings.emptyToNull(titlePattern.replace(params))) + .setVisible(true) + .setEnabled(Boolean.TRUE.equals(enabled)); + } + +} diff --git a/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtUtil.java b/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtUtil.java new file mode 100644 index 0000000..fe35a3c --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/qtcodereview/QtUtil.java @@ -0,0 +1,168 @@ +// Copyright (C) 2011 The Android Open Source Project +// Copyright (C) 2014 Digia Plc and/or its subsidiary(-ies). +// Copyright (C) 2019 The Qt Company +// +// 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.flogger.FluentLogger; +import com.google.gerrit.reviewdb.client.Branch; +import com.google.gerrit.server.project.NoSuchRefException; +import com.google.inject.Singleton; + +import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectInserter; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.RefUpdate; +import org.eclipse.jgit.lib.RefUpdate.Result; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.merge.MergeStrategy; +import org.eclipse.jgit.merge.ThreeWayMerger; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; + +import java.io.IOException; + + +/** + * Utility methods for working with git and database. + */ +@Singleton +public class QtUtil { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + public static final String R_HEADS = "refs/heads/"; + public static final String R_STAGING = "refs/staging/"; + + public static class MergeConflictException extends Exception { + private static final long serialVersionUID = 1L; + public MergeConflictException(final String message) { + super(message); + } + } + + /** + * Gets a staging branch for a branch. + * @param branch Branch under refs/heads. E.g. refs/heads/master. Can be short + * name. + * @return Matching staging branch. E.g. refs/staging/master + */ + public static Branch.NameKey getStagingBranch(final Branch.NameKey branch) { + return getBranchWithNewPrefix(branch, R_HEADS, R_STAGING); + } + + private static Branch.NameKey getBranchWithNewPrefix(final Branch.NameKey branch, + final String oldPrefix, + final String newPrefix) { + final String ref = branch.get(); + + if (ref.startsWith(oldPrefix)) { + // Create new ref replacing the old prefix with new. + return new Branch.NameKey(branch.getParentKey(), newPrefix + ref.substring(oldPrefix.length())); + } + // Treat the ref as short name. + return new Branch.NameKey(branch.getParentKey(), newPrefix + ref); + } + + public static Result createStagingBranch(Repository git, + final Branch.NameKey sourceBranch) { + try { + final String sourceBranchName; + if (sourceBranch.get().startsWith(R_HEADS)) { + sourceBranchName = sourceBranch.get(); + } else { + sourceBranchName = R_HEADS + sourceBranch.get(); + } + + final String stagingBranch = R_STAGING + sourceBranch.getShortName(); + + return updateRef(git, stagingBranch, sourceBranchName, true); + } catch (NoSuchRefException | IOException e ) { + return null; + } + } + + private static Result updateRef(Repository git, + final String ref, + final String newValue, + final boolean force) + throws IOException, NoSuchRefException { + Ref sourceRef = git.getRefDatabase().getRef(newValue); + if (sourceRef == null) { + throw new NoSuchRefException(newValue); + } + + return updateRef(git, ref, sourceRef.getObjectId(), force); + } + + public static Result updateRef(Repository git, + final String ref, + final ObjectId id, + final boolean force) + throws IOException, NoSuchRefException { + RefUpdate refUpdate = git.updateRef(ref); + refUpdate.setNewObjectId(id); + refUpdate.setForceUpdate(force); + RefUpdate.Result result = refUpdate.update(); + return result; + } + + public static RevCommit merge(PersonIdent committerIdent, + Repository git, + ObjectInserter objInserter, + RevWalk revWalk, + RevCommit toMerge, + RevCommit mergeTip, + boolean mergeAlways) + throws NoSuchRefException, IOException, MergeConflictException { + + if (revWalk.isMergedInto(toMerge, mergeTip)) { + logger.atWarning().log("qtcodereview: commit %s already in %s", toMerge, mergeTip); + return mergeTip; // already up to date + } + + ThreeWayMerger merger = MergeStrategy.RESOLVE.newMerger(git, true); + if (!merger.merge(mergeTip, toMerge)) { + logger.atWarning().log("qtcodereview: merge conflict %s on top of %s", toMerge, mergeTip); + throw new MergeConflictException("Merge conflict"); + } + + if (!mergeAlways && merger.getResultTreeId().equals(toMerge.getTree().toObjectId())) { + // Just fast forward, note that this will bring in all dependencies from source + logger.atInfo().log("qtcodereview: merge fast forward %s on top of %s", toMerge, mergeTip); + return toMerge; + } + + String message; + try { + message = revWalk.parseCommit(toMerge).getShortMessage(); + } catch (Exception e) { + message = toMerge.toString(); + } + message = "Merge \"" + message + "\""; + + final CommitBuilder mergeCommit = new CommitBuilder(); + mergeCommit.setTreeId(merger.getResultTreeId()); + mergeCommit.setParentIds(mergeTip, toMerge); + mergeCommit.setAuthor(toMerge.getAuthorIdent()); + mergeCommit.setCommitter(committerIdent); + mergeCommit.setMessage(message); + + return revWalk.parseCommit(objInserter.insert(mergeCommit)); + } + +} 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 965f6d2..168f2e1 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/qtcodereview/QtCodeReviewIT.java +++ b/src/test/java/com/googlesource/gerrit/plugins/qtcodereview/QtCodeReviewIT.java @@ -1,18 +1,36 @@ -// Copyright (C) 2018 The Qt Company +// Copyright (C) 2019 The Qt Company package com.googlesource.gerrit.plugins.qtcodereview; import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT; +import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION; +import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS; +import com.google.common.collect.Lists; import com.google.gerrit.acceptance.LightweightPluginDaemonTest; import com.google.gerrit.acceptance.PushOneCommit; import com.google.gerrit.acceptance.RestResponse; +import com.google.gerrit.acceptance.TestAccount; import com.google.gerrit.acceptance.TestPlugin; import com.google.gerrit.acceptance.UseSsh; +import com.google.gerrit.extensions.common.ApprovalInfo; +import com.google.gerrit.extensions.common.ChangeInfo; import com.google.gerrit.reviewdb.client.Change; +import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevWalk; +import org.apache.log4j.ConsoleAppender; +import org.apache.log4j.Level; +import org.apache.log4j.Logger; +import org.apache.log4j.LogManager; +import org.apache.log4j.PatternLayout; + +import java.util.List; + +import org.junit.Before; import org.junit.Test; @@ -29,6 +47,26 @@ public class QtCodeReviewIT extends LightweightPluginDaemonTest { protected static final String R_STAGING = "refs/staging/"; protected static final String R_PUSH = "refs/for/"; + protected static final String CONTENT_DATA = "hereisjustsomecontentforthecommits"; + + @Before + public void ReduceLogging() throws Exception { + LogManager.resetConfiguration(); + + final PatternLayout layout = new PatternLayout(); + layout.setConversionPattern("%-5p %c %x: %m%n"); + + final ConsoleAppender dst = new ConsoleAppender(); + dst.setLayout(layout); + dst.setTarget("System.err"); + dst.setThreshold(Level.INFO); + dst.activateOptions(); + + final Logger root = LogManager.getRootLogger(); + root.removeAllAppenders(); + root.addAppender(dst); + } + @Test public void pingSSHTest() throws Exception { assertThat(adminSshSession.exec("gerrit-plugin-qt-workflow ping")).contains("Pong"); @@ -41,6 +79,8 @@ public class QtCodeReviewIT extends LightweightPluginDaemonTest { protected void QtDefer(PushOneCommit.Result c) throws Exception { RestResponse response = call_REST_API_Defer(c.getChangeId()); response.assertOK(); + Change change = c.getChange().change(); + assertThat(change.getStatus()).isEqualTo(Change.Status.DEFERRED); } protected RestResponse call_REST_API_Defer(String changeId) throws Exception { @@ -49,11 +89,25 @@ public class QtCodeReviewIT extends LightweightPluginDaemonTest { return response; } + protected void QtStage(PushOneCommit.Result c) throws Exception { + RestResponse response = call_REST_API_Stage(c.getChangeId(), c.getCommit().getName()); + response.assertOK(); + Change change = c.getChange().change(); + assertThat(change.getStatus()).isEqualTo(Change.Status.STAGED); + resetEvents(); + } + + protected RestResponse call_REST_API_Stage(String changeId, String revisionId) throws Exception { + String url = "/changes/" + changeId + "/revisions/" + revisionId + "/gerrit-plugin-qt-workflow~stage"; + RestResponse response = userRestSession.post(url); + return response; + } + protected PushOneCommit.Result pushCommit(String branch, - String message, - String file, - String content) - throws Exception { + 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(); @@ -61,6 +115,19 @@ public class QtCodeReviewIT extends LightweightPluginDaemonTest { return c; } + protected PushOneCommit.Result amendCommit(String changeId) throws Exception { + String branch = "master"; + String pushRef = R_PUSH + branch; + PushOneCommit push = pushFactory.create(db, + user.getIdent(), + testRepo, + PushOneCommit.SUBJECT, + PushOneCommit.FILE_NAME, + CONTENT_DATA, + changeId); + return push.to(pushRef); + } + 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); @@ -68,8 +135,61 @@ public class QtCodeReviewIT extends LightweightPluginDaemonTest { return result; } + protected void assertCherryPick(RevCommit head, RevCommit source, String cherrypickSHA) { + assertThat(head.getName()).isEqualTo(cherrypickSHA); + assertThat(head).isNotEqualTo(source); + assertThat(cherrypickSHA).isNotEqualTo(source.getName()); + assertThat(head.getShortMessage()).isEqualTo(source.getShortMessage()); + assertThat(head.getFooterLines("Change-Id")).isEqualTo(source.getFooterLines("Change-Id")); + assertThat(head.getParentCount()).isEqualTo(1); + } + + protected void assertApproval(String changeId, TestAccount user) throws Exception { + ChangeInfo c = gApi.changes().id(changeId).get(DETAILED_LABELS, CURRENT_REVISION, CURRENT_COMMIT); + + String label = "Code-Review"; + int expectedVote = 2; + Integer vote = 0; + if (c.labels.get(label) != null && c.labels.get(label).all != null) { + for (ApprovalInfo approval : c.labels.get(label).all) { + if (approval._accountId == user.id.get()) { + vote = approval.value; + break; + } + } + } + + String name = "label = " + label; + assertThat(vote).named(name).isEqualTo(expectedVote); + } + protected void assertRefUpdatedEvents(String refName, RevCommit ... expected) throws Exception { eventRecorder.assertRefUpdatedEvents(project.get(), refName, expected); } + protected void resetEvents() { + closeEventRecorder(); + startEventRecorder(); + } + + protected String getBranchNameFromRef(String refStr) { + if (refStr.startsWith(R_HEADS)) { + return refStr.substring(R_HEADS.length()); + } else { + return refStr; + } + } + + protected String getCurrentPatchSHA(PushOneCommit.Result c) throws Exception { + return c.getChange().currentPatchSet().getRevision().get(); + } + + protected List getRemoteLog(String ref) throws Exception { + try (Repository repo = repoManager.openRepository(project); + RevWalk rw = new RevWalk(repo)) { + rw.markStart(rw.parseCommit(repo.exactRef(ref).getObjectId())); + return Lists.newArrayList(rw); + } + } + } diff --git a/src/test/java/com/googlesource/gerrit/plugins/qtcodereview/QtStageIT.java b/src/test/java/com/googlesource/gerrit/plugins/qtcodereview/QtStageIT.java new file mode 100644 index 0000000..60be674 --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/qtcodereview/QtStageIT.java @@ -0,0 +1,294 @@ +// Copyright (C) 2019 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.common.collect.ImmutableList; +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.SubmitInput; +import com.google.gerrit.reviewdb.client.Branch; +import com.google.gerrit.reviewdb.client.Change; +import com.google.gerrit.reviewdb.client.ChangeMessage; + +import org.apache.http.HttpStatus; + +import org.eclipse.jgit.revwalk.RevCommit; + +import java.util.ArrayList; + +import org.junit.Before; +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 QtStageIT extends QtCodeReviewIT { + + private final String STAGED_MSG = "Staged for CI"; + + @Before + public void SetDefaultPermissions() throws Exception { + createBranch(new Branch.NameKey(project, "feature")); + + grant(project, "refs/heads/master", Permission.QT_STAGE, false, REGISTERED_USERS); + grant(project, "refs/heads/feature", Permission.QT_STAGE, false, REGISTERED_USERS); + } + + @Test + public void singleChange_Stage() throws Exception { + RevCommit initialHead = getRemoteHead(); + PushOneCommit.Result c = pushCommit("master", "commitmsg1", "file1", "content1"); + approve(c.getChangeId()); + RevCommit stagingHead = qtStage(c); + assertApproval(c.getChangeId(), admin); + } + + @Test + public void multiChange_Stage() throws Exception { + RevCommit initialHead; + RevCommit stagingHead; + + // Push 3 independent commits + initialHead = getRemoteHead(); + PushOneCommit.Result c1 = pushCommit("master", "commitmsg1", "file1", "content1"); + testRepo.reset(initialHead); + PushOneCommit.Result c2 = pushCommit("master", "commitmsg2", "file2", "content2"); + testRepo.reset(initialHead); + PushOneCommit.Result c3 = pushCommit("master", "commitmsg3", "file3", "content3"); + + approve(c1.getChangeId()); + approve(c2.getChangeId()); + approve(c3.getChangeId()); + + stagingHead = qtStage(c1); + stagingHead = qtStage(c2); + stagingHead = qtStage(c3); + } + + @Test + public void mergeCommit_Stage() throws Exception { + RevCommit initialHead = getRemoteHead(); + + // make changes on feature branch + PushOneCommit.Result f1 = pushCommit("feature", "commitmsg1", "file1", "content1"); + PushOneCommit.Result f2 = pushCommit("feature", "commitmsg2", "file2", "content2"); + approve(f1.getChangeId()); + gApi.changes().id(f1.getChangeId()).current().submit(); + approve(f2.getChangeId()); + gApi.changes().id(f2.getChangeId()).current().submit(); + + // make a change on master branch + testRepo.reset(initialHead); + PushOneCommit.Result c1 = pushCommit("master", "commitmsg3", "file3", "content3"); + approve(c1.getChangeId()); + gApi.changes().id(c1.getChangeId()).current().submit(); + + // merge feature branch into master + PushOneCommit mm = pushFactory.create(db, admin.getIdent(), testRepo); + mm.setParents(ImmutableList.of(c1.getCommit(), f2.getCommit())); + PushOneCommit.Result m = mm.to("refs/for/master"); + m.assertOkStatus(); + approve(m.getChangeId()); + RevCommit stagingHead = qtStageExpectMerge(m); + + // check that all commits are in staging ref + String gitLog = getRemoteLog("refs/staging/master").toString(); + assertThat(gitLog).contains(initialHead.getId().name()); + assertThat(gitLog).contains(c1.getCommit().getId().name()); + assertThat(gitLog).contains(f1.getCommit().getId().name()); + assertThat(gitLog).contains(f2.getCommit().getId().name()); + assertThat(gitLog).contains(m.getCommit().getId().name()); + } + + @Test + public void emptyChange_Stage() throws Exception { + RevCommit initialHead = getRemoteHead(); + PushOneCommit.Result c = pushCommit("master", "1st commit", "afile", ""); + approve(c.getChangeId()); + RevCommit stagingHead = qtStage(c); + assertApproval(c.getChangeId(), admin); + + // no changes in this commit + c = pushCommit("master", "no content", "afile", ""); + approve(c.getChangeId()); + stagingHead = qtStage(c); + assertApproval(c.getChangeId(), admin); + } + + @Test + public void errorStage_No_Permission() throws Exception { + deny(project, "refs/heads/master", Permission.QT_STAGE, REGISTERED_USERS); + + RevCommit initialHead = getRemoteHead(); + PushOneCommit.Result c = pushCommit("master", "commitmsg1", "file1", "content1"); + approve(c.getChangeId()); + + RestResponse response = qtStageExpectFail(c, initialHead, initialHead, HttpStatus.SC_FORBIDDEN); + assertThat(response.getEntityContent()).contains("not permitted"); + + grant(project, "refs/heads/master", Permission.QT_STAGE, false, REGISTERED_USERS); + } + + @Test + public void errorStage_Wrong_Status() throws Exception { + RevCommit initialHead = getRemoteHead(); + PushOneCommit.Result c = pushCommit("master", "commitmsg1", "file1", "content1"); + approve(c.getChangeId()); + + grant(project, "refs/heads/master", Permission.ABANDON, false, REGISTERED_USERS); + QtDefer(c); + deny(project, "refs/heads/master", Permission.ABANDON, REGISTERED_USERS); + + RestResponse response = qtStageExpectFail(c, initialHead, initialHead, HttpStatus.SC_CONFLICT); + assertThat(response.getEntityContent()).contains("change is DEFERRED"); + } + + @Test + public void errorStage_Invalid_ChangeId() throws Exception { + PushOneCommit.Result c = pushCommit("master", "commitmsg1", "file1", "content1"); + + RestResponse response = call_REST_API_Stage("thischangeidnotfound", c.getCommit().getName()); + response.assertStatus(HttpStatus.SC_NOT_FOUND); + assertThat(response.getEntityContent()).contains("Not found: thischangeidnotfound"); + } + + @Test + public void errorStage_Invalid_RevisionId() throws Exception { + PushOneCommit.Result c = pushCommit("master", "commitmsg1", "file1", "content1"); + + RestResponse response = call_REST_API_Stage(c.getChangeId(), "thisrevisionidnotfound"); + response.assertStatus(HttpStatus.SC_NOT_FOUND); + assertThat(response.getEntityContent()).contains("Not found: thisrevisionidnotfound"); + } + + @Test + public void errorStage_Revision_Not_Current() throws Exception { + PushOneCommit.Result c1 = pushCommit("master", "commitmsg1", "file1", "content1"); + PushOneCommit.Result c2 = amendCommit(c1.getChangeId()); + + RestResponse response = call_REST_API_Stage(c1.getChangeId(), c1.getCommit().getName()); + response.assertStatus(HttpStatus.SC_CONFLICT); + assertThat(response.getEntityContent()).contains("is not current revision"); + } + + @Test + public void errorStage_Not_Reviewed() throws Exception { + RevCommit initialHead = getRemoteHead(); + PushOneCommit.Result c = pushCommit("master", "commitmsg1", "file1", "content1"); + + RestResponse response = qtStageExpectFail(c, initialHead, initialHead, HttpStatus.SC_CONFLICT); + assertThat(response.getEntityContent()).contains("needs Code-Review"); + } + + @Test + public void errorAmend_Status_Staged() throws Exception { + RevCommit initialHead = getRemoteHead(); + PushOneCommit.Result c1 = pushCommit("master", "commitmsg1", "file1", "content1"); + approve(c1.getChangeId()); + RevCommit stagingHead = qtStage(c1); + + PushOneCommit.Result c2 = amendCommit(c1.getChangeId()); + c2.assertErrorStatus(" closed"); + + RevCommit updatedHead = getRemoteHead(project, "refs/staging/master"); + assertThat(updatedHead.getId()).isEqualTo(stagingHead.getId()); // not updated + } + + @Test + public void errorStage_Merge_Conlict() throws Exception { + RevCommit initialHead = getRemoteHead(); + PushOneCommit.Result c1 = pushCommit("master", "commitmsg1", "thesamefile", "content"); + approve(c1.getChangeId()); + RevCommit stagingHead1 = qtStage(c1); + + testRepo.reset(initialHead); + PushOneCommit.Result c2 = pushCommit("master", "commitmsg2", "thesamefile", "conficting content"); + approve(c2.getChangeId()); + RestResponse response = qtStageExpectFail(c2, initialHead, stagingHead1, HttpStatus.SC_CONFLICT); + assertThat(response.getEntityContent()).contains("merge conflict"); + + Change change = c2.getChange().change(); + assertThat(change.getStatus()).isEqualTo(Change.Status.NEW); + } + + private RevCommit qtStage(PushOneCommit.Result c) throws Exception { + return qtStage(c, false); + } + + private RevCommit qtStageExpectMerge(PushOneCommit.Result c) throws Exception { + return qtStage(c, true); + } + + private RevCommit qtStage(PushOneCommit.Result c, boolean merge) throws Exception { + String branch = getBranchNameFromRef(c.getChange().change().getDest().get()); + String stagingRef = R_STAGING + branch; + String branchRef = R_HEADS + branch; + RevCommit originalCommit = c.getCommit(); + RevCommit initialHead = getRemoteHead(project, branchRef); + RevCommit oldStagingHead = getRemoteHead(project, stagingRef); + if (oldStagingHead==null) oldStagingHead = initialHead; + + RestResponse response = call_REST_API_Stage(c.getChangeId(), originalCommit.getName()); + response.assertOK(); + + RevCommit branchHead = getRemoteHead(project, branchRef); + assertThat(branchHead.getId()).isEqualTo(initialHead.getId()); // master is not updated + + RevCommit stagingHead = getRemoteHead(project, stagingRef); + + if (merge) { + assertThat(stagingHead.getParentCount()).isEqualTo(2); + assertThat(stagingHead.getParent(1)).isEqualTo(originalCommit); + } else { + assertCherryPick(stagingHead, originalCommit, getCurrentPatchSHA(c)); + } + assertThat(stagingHead.getParent(0)).isEqualTo(oldStagingHead); + assertRefUpdatedEvents(stagingRef, oldStagingHead, stagingHead); + resetEvents(); + + Change change = c.getChange().change(); + assertThat(change.getStatus()).isEqualTo(Change.Status.STAGED); + + ArrayList messages = new ArrayList(c.getChange().messages()); + assertThat(messages.get(messages.size()-1).getMessage()).isEqualTo(STAGED_MSG); // check last message + + return stagingHead; + } + + private RestResponse qtStageExpectFail(PushOneCommit.Result c, + RevCommit initialHead, + RevCommit oldStagingHead, + int expectedStatus) + throws Exception { + String branch = getBranchNameFromRef(c.getChange().change().getDest().get()); + String stagingRef = R_STAGING + branch; + String branchRef = R_HEADS + branch; + + RestResponse response = call_REST_API_Stage(c.getChangeId(), c.getCommit().getName()); + response.assertStatus(expectedStatus); + + RevCommit branchHead = getRemoteHead(project, branchRef); + assertThat(branchHead.getId()).isEqualTo(initialHead.getId()); // master is not updated + + RevCommit stagingHead = getRemoteHead(project, stagingRef); + if (stagingHead != null) assertThat(stagingHead.getId()).isEqualTo(oldStagingHead.getId()); // staging is not updated + + assertRefUpdatedEvents(branchRef); // no events + assertRefUpdatedEvents(stagingRef); // no events + + return response; + } + +} -- cgit v1.2.3