| // 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.sshd.commands; |
| |
| import com.google.common.base.Splitter; |
| import com.google.common.base.Strings; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.Maps; |
| import com.google.gerrit.common.data.LabelType; |
| import com.google.gerrit.common.data.LabelValue; |
| import com.google.gerrit.common.data.ReviewResult; |
| import com.google.gerrit.common.data.ReviewResult.Error.Type; |
| import com.google.gerrit.extensions.restapi.AuthException; |
| import com.google.gerrit.extensions.restapi.BadRequestException; |
| import com.google.gerrit.extensions.restapi.ResourceConflictException; |
| import com.google.gerrit.reviewdb.client.Change; |
| import com.google.gerrit.reviewdb.client.PatchSet; |
| import com.google.gerrit.reviewdb.client.RevId; |
| import com.google.gerrit.reviewdb.server.ReviewDb; |
| import com.google.gerrit.server.change.Abandon; |
| import com.google.gerrit.server.change.ChangeResource; |
| import com.google.gerrit.server.change.PostReview; |
| import com.google.gerrit.server.change.Restore; |
| import com.google.gerrit.server.change.RevisionResource; |
| import com.google.gerrit.server.change.Submit; |
| import com.google.gerrit.server.changedetail.DeleteDraftPatchSet; |
| import com.google.gerrit.server.changedetail.PublishDraft; |
| import com.google.gerrit.server.config.AllProjectsName; |
| import com.google.gerrit.server.project.ChangeControl; |
| import com.google.gerrit.server.project.InvalidChangeOperationException; |
| import com.google.gerrit.server.project.NoSuchChangeException; |
| import com.google.gerrit.server.project.NoSuchProjectException; |
| import com.google.gerrit.server.project.ProjectControl; |
| import com.google.gerrit.sshd.CommandMetaData; |
| import com.google.gerrit.sshd.SshCommand; |
| import com.google.gerrit.util.cli.CmdLineParser; |
| import com.google.gwtorm.server.OrmException; |
| import com.google.gwtorm.server.ResultSet; |
| import com.google.inject.Inject; |
| import com.google.inject.Provider; |
| |
| import org.kohsuke.args4j.Argument; |
| import org.kohsuke.args4j.Option; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| import java.io.IOException; |
| import java.util.ArrayList; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| |
| @CommandMetaData(name = "review", descr = "Verify, approve and/or submit one or more patch sets") |
| public class ReviewCommand extends SshCommand { |
| private static final Logger log = |
| LoggerFactory.getLogger(ReviewCommand.class); |
| |
| @Override |
| protected final CmdLineParser newCmdLineParser(Object options) { |
| final CmdLineParser parser = super.newCmdLineParser(options); |
| for (ApproveOption c : optionList) { |
| parser.addOption(c, c); |
| } |
| return parser; |
| } |
| |
| private final Set<PatchSet> patchSets = new HashSet<PatchSet>(); |
| |
| @Argument(index = 0, required = true, multiValued = true, metaVar = "{COMMIT | CHANGE,PATCHSET}", usage = "list of commits or patch sets to review") |
| void addPatchSetId(final String token) { |
| try { |
| patchSets.add(parsePatchSet(token)); |
| } catch (UnloggedFailure e) { |
| throw new IllegalArgumentException(e.getMessage(), e); |
| } catch (OrmException e) { |
| throw new IllegalArgumentException("database error", e); |
| } |
| } |
| |
| @Option(name = "--project", aliases = "-p", usage = "project containing the specified patch set(s)") |
| private ProjectControl projectControl; |
| |
| @Option(name = "--message", aliases = "-m", usage = "cover message to publish on change(s)", metaVar = "MESSAGE") |
| private String changeComment; |
| |
| @Option(name = "--abandon", usage = "abandon the specified change(s)") |
| private boolean abandonChange; |
| |
| @Option(name = "--restore", usage = "restore the specified abandoned change(s)") |
| private boolean restoreChange; |
| |
| @Option(name = "--submit", aliases = "-s", usage = "submit the specified patch set(s)") |
| private boolean submitChange; |
| |
| @Option(name = "--force-message", usage = "publish the message, " |
| + "even if the label score cannot be applied due to the change being closed") |
| private boolean forceMessage = false; |
| |
| @Option(name = "--publish", usage = "publish the specified draft patch set(s)") |
| private boolean publishPatchSet; |
| |
| @Option(name = "--delete", usage = "delete the specified draft patch set(s)") |
| private boolean deleteDraftPatchSet; |
| |
| @Option(name = "--label", aliases = "-l", usage = "custom label(s) to assign", metaVar = "LABEL=VALUE") |
| void addLabel(final String token) { |
| List<String> parts = ImmutableList.copyOf(Splitter.on('=').split(token)); |
| if (parts.size() != 2) { |
| throw new IllegalArgumentException("invalid custom label " + token); |
| } |
| short value; |
| try { |
| value = Short.parseShort(parts.get(1)); |
| } catch (IllegalArgumentException e) { |
| throw new IllegalArgumentException("invalid custom label value " |
| + parts.get(1)); |
| } |
| customLabels.put(parts.get(0), value); |
| } |
| |
| @Inject |
| private ReviewDb db; |
| |
| @Inject |
| private DeleteDraftPatchSet.Factory deleteDraftPatchSetFactory; |
| |
| @Inject |
| private ProjectControl.Factory projectControlFactory; |
| |
| @Inject |
| private AllProjectsName allProjects; |
| |
| @Inject |
| private ChangeControl.Factory changeControlFactory; |
| |
| @Inject |
| private Provider<Abandon> abandonProvider; |
| |
| @Inject |
| private Provider<PostReview> reviewProvider; |
| |
| @Inject |
| private PublishDraft.Factory publishDraftFactory; |
| |
| @Inject |
| private Provider<Restore> restoreProvider; |
| |
| @Inject |
| private Provider<Submit> submitProvider; |
| |
| private List<ApproveOption> optionList; |
| private Map<String, Short> customLabels; |
| |
| @Override |
| protected void run() throws UnloggedFailure { |
| if (abandonChange) { |
| if (restoreChange) { |
| throw error("abandon and restore actions are mutually exclusive"); |
| } |
| if (submitChange) { |
| throw error("abandon and submit actions are mutually exclusive"); |
| } |
| if (publishPatchSet) { |
| throw error("abandon and publish actions are mutually exclusive"); |
| } |
| if (deleteDraftPatchSet) { |
| throw error("abandon and delete actions are mutually exclusive"); |
| } |
| } |
| if (publishPatchSet) { |
| if (restoreChange) { |
| throw error("publish and restore actions are mutually exclusive"); |
| } |
| if (submitChange) { |
| throw error("publish and submit actions are mutually exclusive"); |
| } |
| if (deleteDraftPatchSet) { |
| throw error("publish and delete actions are mutually exclusive"); |
| } |
| } |
| |
| boolean ok = true; |
| for (final PatchSet patchSet : patchSets) { |
| try { |
| approveOne(patchSet); |
| } catch (UnloggedFailure e) { |
| ok = false; |
| writeError("error: " + e.getMessage() + "\n"); |
| } catch (NoSuchChangeException e) { |
| ok = false; |
| writeError("no such change " + patchSet.getId().getParentKey().get()); |
| } catch (Exception e) { |
| ok = false; |
| writeError("fatal: internal server error while approving " |
| + patchSet.getId() + "\n"); |
| log.error("internal error while approving " + patchSet.getId(), e); |
| } |
| } |
| |
| if (!ok) { |
| throw new UnloggedFailure(1, "one or more approvals failed;" |
| + " review output above"); |
| } |
| } |
| |
| private void applyReview(final ChangeControl ctl, final PatchSet patchSet, |
| final PostReview.Input review) throws Exception { |
| reviewProvider.get().apply(new RevisionResource( |
| new ChangeResource(ctl), patchSet), review); |
| } |
| |
| private void approveOne(final PatchSet patchSet) throws Exception { |
| |
| if (changeComment == null) { |
| changeComment = ""; |
| } |
| |
| PostReview.Input review = new PostReview.Input(); |
| review.message = Strings.emptyToNull(changeComment); |
| review.labels = Maps.newTreeMap(); |
| review.drafts = PostReview.DraftHandling.PUBLISH; |
| review.strictLabels = false; |
| for (ApproveOption ao : optionList) { |
| Short v = ao.value(); |
| if (v != null) { |
| review.labels.put(ao.getLabelName(), v); |
| } |
| } |
| review.labels.putAll(customLabels); |
| |
| // If review labels are being applied, the comment will be included |
| // on the review note. We don't need to add it again on the abandon |
| // or restore comment. |
| if (!review.labels.isEmpty() && (abandonChange || restoreChange)) { |
| changeComment = null; |
| } |
| |
| try { |
| ChangeControl ctl = |
| changeControlFactory.controlFor(patchSet.getId().getParentKey()); |
| |
| if (abandonChange) { |
| final Abandon abandon = abandonProvider.get(); |
| final Abandon.Input input = new Abandon.Input(); |
| input.message = changeComment; |
| applyReview(ctl, patchSet, review); |
| try { |
| abandon.apply(new ChangeResource(ctl), input); |
| } catch (AuthException e) { |
| writeError("error: " + parseError(Type.ABANDON_NOT_PERMITTED) + "\n"); |
| } catch (ResourceConflictException e) { |
| writeError("error: " + parseError(Type.CHANGE_IS_CLOSED) + "\n"); |
| } |
| } else if (restoreChange) { |
| final Restore restore = restoreProvider.get(); |
| final Restore.Input input = new Restore.Input(); |
| input.message = changeComment; |
| try { |
| restore.apply(new ChangeResource(ctl), input); |
| applyReview(ctl, patchSet, review); |
| } catch (AuthException e) { |
| writeError("error: " + parseError(Type.RESTORE_NOT_PERMITTED) + "\n"); |
| } catch (ResourceConflictException e) { |
| writeError("error: " + parseError(Type.CHANGE_NOT_ABANDONED) + "\n"); |
| } |
| } else { |
| applyReview(ctl, patchSet, review); |
| } |
| |
| if (submitChange) { |
| Submit submit = submitProvider.get(); |
| Submit.Input input = new Submit.Input(); |
| input.waitForMerge = true; |
| submit.apply(new RevisionResource( |
| new ChangeResource(ctl), patchSet), |
| input); |
| } |
| } catch (InvalidChangeOperationException e) { |
| throw error(e.getMessage()); |
| } catch (IllegalStateException e) { |
| throw error(e.getMessage()); |
| } catch (AuthException e) { |
| throw error(e.getMessage()); |
| } catch (BadRequestException e) { |
| throw error(e.getMessage()); |
| } catch (ResourceConflictException e) { |
| throw error(e.getMessage()); |
| } |
| |
| if (publishPatchSet) { |
| final ReviewResult result = |
| publishDraftFactory.create(patchSet.getId()).call(); |
| handleReviewResultErrors(result); |
| } else if (deleteDraftPatchSet) { |
| final ReviewResult result = |
| deleteDraftPatchSetFactory.create(patchSet.getId()).call(); |
| handleReviewResultErrors(result); |
| } |
| } |
| |
| private void handleReviewResultErrors(final ReviewResult result) { |
| for (ReviewResult.Error resultError : result.getErrors()) { |
| String errMsg = "error: (change " + result.getChangeId() + ") "; |
| errMsg += parseError(resultError.getType()); |
| if (resultError.getMessage() != null) { |
| errMsg += ": " + resultError.getMessage(); |
| } |
| writeError(errMsg); |
| } |
| } |
| |
| private String parseError(Type type) { |
| switch (type) { |
| case ABANDON_NOT_PERMITTED: |
| return "not permitted to abandon change"; |
| case RESTORE_NOT_PERMITTED: |
| return "not permitted to restore change"; |
| case SUBMIT_NOT_PERMITTED: |
| return "not permitted to submit change"; |
| case SUBMIT_NOT_READY: |
| return "approvals or dependencies lacking"; |
| case CHANGE_IS_CLOSED: |
| return "change is closed"; |
| case CHANGE_NOT_ABANDONED: |
| return "change is not abandoned"; |
| case PUBLISH_NOT_PERMITTED: |
| return "not permitted to publish change"; |
| case DELETE_NOT_PERMITTED: |
| return "not permitted to delete change/patch set"; |
| case RULE_ERROR: |
| return "rule error"; |
| case NOT_A_DRAFT: |
| return "change/patch set is not a draft"; |
| case GIT_ERROR: |
| return "error writing change to git repository"; |
| case DEST_BRANCH_NOT_FOUND: |
| return "destination branch not found"; |
| default: |
| return "failure in review"; |
| } |
| } |
| |
| private PatchSet parsePatchSet(final String patchIdentity) |
| throws UnloggedFailure, OrmException { |
| // By commit? |
| // |
| if (patchIdentity.matches("^([0-9a-fA-F]{4," + RevId.LEN + "})$")) { |
| final RevId id = new RevId(patchIdentity); |
| final ResultSet<PatchSet> patches; |
| if (id.isComplete()) { |
| patches = db.patchSets().byRevision(id); |
| } else { |
| patches = db.patchSets().byRevisionRange(id, id.max()); |
| } |
| |
| final Set<PatchSet> matches = new HashSet<PatchSet>(); |
| for (final PatchSet ps : patches) { |
| final Change change = db.changes().get(ps.getId().getParentKey()); |
| if (inProject(change)) { |
| matches.add(ps); |
| } |
| } |
| |
| switch (matches.size()) { |
| case 1: |
| return matches.iterator().next(); |
| case 0: |
| throw error("\"" + patchIdentity + "\" no such patch set"); |
| default: |
| throw error("\"" + patchIdentity + "\" matches multiple patch sets"); |
| } |
| } |
| |
| // By older style change,patchset? |
| // |
| if (patchIdentity.matches("^[1-9][0-9]*,[1-9][0-9]*$")) { |
| final PatchSet.Id patchSetId; |
| try { |
| patchSetId = PatchSet.Id.parse(patchIdentity); |
| } catch (IllegalArgumentException e) { |
| throw error("\"" + patchIdentity + "\" is not a valid patch set"); |
| } |
| final PatchSet patchSet = db.patchSets().get(patchSetId); |
| if (patchSet == null) { |
| throw error("\"" + patchIdentity + "\" no such patch set"); |
| } |
| if (projectControl != null) { |
| final Change change = db.changes().get(patchSetId.getParentKey()); |
| if (!inProject(change)) { |
| throw error("change " + change.getId() + " not in project " |
| + projectControl.getProject().getName()); |
| } |
| } |
| return patchSet; |
| } |
| |
| throw error("\"" + patchIdentity + "\" is not a valid patch set"); |
| } |
| |
| private boolean inProject(final Change change) { |
| if (projectControl == null) { |
| // No --project option, so they want every project. |
| return true; |
| } |
| return projectControl.getProject().getNameKey().equals(change.getProject()); |
| } |
| |
| @Override |
| protected void parseCommandLine() throws UnloggedFailure { |
| optionList = new ArrayList<ApproveOption>(); |
| customLabels = Maps.newHashMap(); |
| |
| ProjectControl allProjectsControl; |
| try { |
| allProjectsControl = projectControlFactory.controlFor(allProjects); |
| } catch (NoSuchProjectException e) { |
| throw new UnloggedFailure("missing " + allProjects.get()); |
| } |
| |
| for (LabelType type : allProjectsControl.getLabelTypes().getLabelTypes()) { |
| String usage = ""; |
| usage = "score for " + type.getName() + "\n"; |
| |
| for (LabelValue v : type.getValues()) { |
| usage += v.format() + "\n"; |
| } |
| |
| final String name = "--" + type.getName().toLowerCase(); |
| optionList.add(new ApproveOption(name, usage, type)); |
| } |
| |
| super.parseCommandLine(); |
| } |
| |
| private void writeError(final String msg) { |
| try { |
| err.write(msg.getBytes(ENC)); |
| } catch (IOException e) { |
| } |
| } |
| |
| private static UnloggedFailure error(final String msg) { |
| return new UnloggedFailure(1, msg); |
| } |
| } |