Line data Source code
1 : // Copyright (C) 2009 The Android Open Source Project
2 : //
3 : // Licensed under the Apache License, Version 2.0 (the "License");
4 : // you may not use this file except in compliance with the License.
5 : // You may obtain a copy of the License at
6 : //
7 : // http://www.apache.org/licenses/LICENSE-2.0
8 : //
9 : // Unless required by applicable law or agreed to in writing, software
10 : // distributed under the License is distributed on an "AS IS" BASIS,
11 : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 : // See the License for the specific language governing permissions and
13 : // limitations under the License.
14 :
15 : package com.google.gerrit.sshd.commands;
16 :
17 : import static com.google.gerrit.util.cli.Localizable.localizable;
18 : import static java.nio.charset.StandardCharsets.UTF_8;
19 : import static java.util.Objects.requireNonNull;
20 :
21 : import com.google.common.base.Strings;
22 : import com.google.common.collect.ImmutableList;
23 : import com.google.common.flogger.FluentLogger;
24 : import com.google.common.io.CharStreams;
25 : import com.google.gerrit.entities.LabelType;
26 : import com.google.gerrit.entities.LabelValue;
27 : import com.google.gerrit.entities.PatchSet;
28 : import com.google.gerrit.exceptions.StorageException;
29 : import com.google.gerrit.extensions.api.GerritApi;
30 : import com.google.gerrit.extensions.api.changes.AbandonInput;
31 : import com.google.gerrit.extensions.api.changes.ChangeApi;
32 : import com.google.gerrit.extensions.api.changes.MoveInput;
33 : import com.google.gerrit.extensions.api.changes.NotifyHandling;
34 : import com.google.gerrit.extensions.api.changes.RestoreInput;
35 : import com.google.gerrit.extensions.api.changes.ReviewInput;
36 : import com.google.gerrit.extensions.api.changes.RevisionApi;
37 : import com.google.gerrit.extensions.restapi.RestApiException;
38 : import com.google.gerrit.json.OutputFormat;
39 : import com.google.gerrit.server.DynamicOptions;
40 : import com.google.gerrit.server.config.AllProjectsName;
41 : import com.google.gerrit.server.project.NoSuchChangeException;
42 : import com.google.gerrit.server.project.ProjectCache;
43 : import com.google.gerrit.server.project.ProjectState;
44 : import com.google.gerrit.server.update.RetryHelper;
45 : import com.google.gerrit.server.update.RetryableAction.ActionType;
46 : import com.google.gerrit.server.util.LabelVote;
47 : import com.google.gerrit.sshd.CommandMetaData;
48 : import com.google.gerrit.sshd.SshCommand;
49 : import com.google.gerrit.util.cli.CmdLineParser;
50 : import com.google.gerrit.util.cli.OptionUtil;
51 : import com.google.gson.JsonSyntaxException;
52 : import com.google.inject.Inject;
53 : import java.io.IOException;
54 : import java.io.InputStreamReader;
55 : import java.lang.reflect.AnnotatedElement;
56 : import java.util.HashMap;
57 : import java.util.HashSet;
58 : import java.util.LinkedHashMap;
59 : import java.util.Map;
60 : import java.util.Optional;
61 : import java.util.Set;
62 : import java.util.TreeMap;
63 : import org.kohsuke.args4j.Argument;
64 : import org.kohsuke.args4j.CmdLineException;
65 : import org.kohsuke.args4j.Option;
66 : import org.kohsuke.args4j.OptionDef;
67 : import org.kohsuke.args4j.spi.FieldSetter;
68 : import org.kohsuke.args4j.spi.OneArgumentOptionHandler;
69 : import org.kohsuke.args4j.spi.Setter;
70 :
71 : @CommandMetaData(name = "review", description = "Apply reviews to one or more patch sets")
72 2 : public class ReviewCommand extends SshCommand {
73 2 : private static final FluentLogger logger = FluentLogger.forEnclosingClass();
74 :
75 : @Override
76 : protected final CmdLineParser newCmdLineParser(Object options) {
77 2 : CmdLineParser parser = super.newCmdLineParser(options);
78 2 : optionMap.forEach((o, s) -> parser.addOption(s, o));
79 2 : return parser;
80 : }
81 :
82 2 : private final Set<PatchSet> patchSets = new HashSet<>();
83 :
84 : @Argument(
85 : index = 0,
86 : required = true,
87 : multiValued = true,
88 : metaVar = "{COMMIT | CHANGE,PATCHSET}",
89 : usage = "list of commits or patch sets to review")
90 : void addPatchSetId(String token) {
91 : try {
92 2 : PatchSet ps = psParser.parsePatchSet(token, projectState, branch);
93 2 : patchSets.add(ps);
94 0 : } catch (UnloggedFailure e) {
95 0 : throw new IllegalArgumentException(e.getMessage(), e);
96 0 : } catch (StorageException e) {
97 0 : throw new IllegalArgumentException("database error", e);
98 2 : }
99 2 : }
100 :
101 : @Option(
102 : name = "--project",
103 : aliases = "-p",
104 : usage = "project containing the specified patch set(s)")
105 : private ProjectState projectState;
106 :
107 : @Option(name = "--branch", aliases = "-b", usage = "branch containing the specified patch set(s)")
108 : private String branch;
109 :
110 : @Option(
111 : name = "--message",
112 : aliases = "-m",
113 : usage = "cover message to publish on change(s)",
114 : metaVar = "MESSAGE")
115 : private String changeComment;
116 :
117 : @Option(
118 : name = "--notify",
119 : aliases = "-n",
120 : usage = "Who to send email notifications to after the review is stored.",
121 : metaVar = "NOTIFYHANDLING")
122 : private NotifyHandling notify;
123 :
124 : @Option(name = "--abandon", usage = "abandon the specified change(s)")
125 : private boolean abandonChange;
126 :
127 : @Option(name = "--restore", usage = "restore the specified abandoned change(s)")
128 : private boolean restoreChange;
129 :
130 : @Option(name = "--rebase", usage = "rebase the specified change(s)")
131 : private boolean rebaseChange;
132 :
133 : @Option(name = "--move", usage = "move the specified change(s)", metaVar = "BRANCH")
134 : private String moveToBranch;
135 :
136 : @Option(name = "--submit", aliases = "-s", usage = "submit the specified patch set(s)")
137 : private boolean submitChange;
138 :
139 : @Option(name = "--json", aliases = "-j", usage = "read review input json from stdin")
140 : private boolean json;
141 :
142 : @Option(
143 : name = "--tag",
144 : aliases = "-t",
145 : usage = "applies a tag to the given review",
146 : metaVar = "TAG")
147 : private String changeTag;
148 :
149 : @Option(
150 : name = "--label",
151 : aliases = "-l",
152 : usage = "custom label(s) to assign",
153 : metaVar = "LABEL=VALUE")
154 : void addLabel(String token) {
155 0 : LabelVote v = LabelVote.parseWithEquals(token);
156 0 : LabelType.checkName(v.label()); // Disallow SUBM.
157 0 : customLabels.put(v.label(), v.value());
158 0 : }
159 :
160 : @Inject private ProjectCache projectCache;
161 :
162 : @Inject private AllProjectsName allProjects;
163 :
164 : @Inject private GerritApi gApi;
165 :
166 : @Inject private PatchSetParser psParser;
167 :
168 : @Inject private RetryHelper retryHelper;
169 :
170 : private Map<Option, LabelSetter> optionMap;
171 : private Map<String, Short> customLabels;
172 :
173 : @Override
174 : protected void run() throws UnloggedFailure {
175 2 : enableGracefulStop();
176 2 : if (abandonChange) {
177 1 : if (restoreChange) {
178 0 : throw die("abandon and restore actions are mutually exclusive");
179 : }
180 1 : if (submitChange) {
181 0 : throw die("abandon and submit actions are mutually exclusive");
182 : }
183 1 : if (rebaseChange) {
184 0 : throw die("abandon and rebase actions are mutually exclusive");
185 : }
186 1 : if (moveToBranch != null) {
187 0 : throw die("abandon and move actions are mutually exclusive");
188 : }
189 : }
190 2 : if (json) {
191 0 : if (restoreChange) {
192 0 : throw die("json and restore actions are mutually exclusive");
193 : }
194 0 : if (submitChange) {
195 0 : throw die("json and submit actions are mutually exclusive");
196 : }
197 0 : if (abandonChange) {
198 0 : throw die("json and abandon actions are mutually exclusive");
199 : }
200 0 : if (changeComment != null) {
201 0 : throw die("json and message are mutually exclusive");
202 : }
203 0 : if (rebaseChange) {
204 0 : throw die("json and rebase actions are mutually exclusive");
205 : }
206 0 : if (moveToBranch != null) {
207 0 : throw die("json and move actions are mutually exclusive");
208 : }
209 0 : if (changeTag != null) {
210 0 : throw die("json and tag actions are mutually exclusive");
211 : }
212 : }
213 2 : if (rebaseChange) {
214 0 : if (submitChange) {
215 0 : throw die("rebase and submit actions are mutually exclusive");
216 : }
217 : }
218 :
219 2 : boolean ok = true;
220 2 : ReviewInput input = null;
221 2 : if (json) {
222 0 : input = reviewFromJson();
223 : }
224 :
225 2 : for (PatchSet patchSet : patchSets) {
226 : try {
227 2 : if (input != null) {
228 0 : applyReview(patchSet, input);
229 : } else {
230 2 : reviewPatchSet(patchSet);
231 : }
232 0 : } catch (RestApiException | UnloggedFailure e) {
233 0 : ok = false;
234 0 : writeError("error", e.getMessage() + "\n");
235 0 : } catch (NoSuchChangeException e) {
236 0 : ok = false;
237 0 : writeError("error", "no such change " + patchSet.id().changeId().get());
238 0 : } catch (Exception e) {
239 0 : ok = false;
240 0 : writeError("fatal", "internal server error while reviewing " + patchSet.id() + "\n");
241 0 : logger.atSevere().withCause(e).log("internal error while reviewing %s", patchSet.id());
242 2 : }
243 2 : }
244 :
245 2 : if (!ok) {
246 0 : throw die("one or more reviews failed; review output above");
247 : }
248 2 : }
249 :
250 : private void applyReview(PatchSet patchSet, ReviewInput review) throws Exception {
251 2 : retryHelper
252 2 : .action(
253 : ActionType.CHANGE_UPDATE,
254 : "applyReview",
255 : () -> {
256 2 : gApi.changes()
257 2 : .id(patchSet.id().changeId().get())
258 2 : .revision(patchSet.commitId().name())
259 2 : .review(review);
260 2 : return null;
261 : })
262 2 : .call();
263 2 : }
264 :
265 : private ReviewInput reviewFromJson() throws UnloggedFailure {
266 0 : try (InputStreamReader r = new InputStreamReader(in, UTF_8)) {
267 0 : return OutputFormat.JSON.newGson().fromJson(CharStreams.toString(r), ReviewInput.class);
268 0 : } catch (IOException | JsonSyntaxException e) {
269 0 : writeError("error", e.getMessage() + '\n');
270 0 : throw die("internal error while reading review input");
271 : }
272 : }
273 :
274 : private void reviewPatchSet(PatchSet patchSet) throws Exception {
275 :
276 2 : ReviewInput review = new ReviewInput();
277 2 : review.message = Strings.emptyToNull(changeComment);
278 2 : review.tag = Strings.emptyToNull(changeTag);
279 2 : review.notify = notify;
280 2 : review.labels = new TreeMap<>();
281 2 : review.drafts = ReviewInput.DraftHandling.PUBLISH;
282 2 : for (LabelSetter setter : optionMap.values()) {
283 2 : setter.getValue().ifPresent(v -> review.labels.put(setter.getLabelName(), v));
284 2 : }
285 2 : review.labels.putAll(customLabels);
286 :
287 : // We don't need to add the review comment when abandoning/restoring.
288 2 : if (abandonChange || restoreChange || moveToBranch != null) {
289 1 : review.message = null;
290 : }
291 :
292 : try {
293 2 : if (abandonChange) {
294 1 : AbandonInput input = new AbandonInput();
295 1 : input.message = Strings.emptyToNull(changeComment);
296 1 : applyReview(patchSet, review);
297 1 : changeApi(patchSet).abandon(input);
298 2 : } else if (restoreChange) {
299 1 : RestoreInput input = new RestoreInput();
300 1 : input.message = Strings.emptyToNull(changeComment);
301 1 : changeApi(patchSet).restore(input);
302 1 : applyReview(patchSet, review);
303 1 : } else {
304 1 : applyReview(patchSet, review);
305 : }
306 :
307 2 : if (moveToBranch != null) {
308 0 : MoveInput moveInput = new MoveInput();
309 0 : moveInput.destinationBranch = moveToBranch;
310 0 : moveInput.message = Strings.emptyToNull(changeComment);
311 0 : changeApi(patchSet).move(moveInput);
312 : }
313 :
314 2 : if (rebaseChange) {
315 0 : revisionApi(patchSet).rebase();
316 : }
317 :
318 2 : if (submitChange) {
319 0 : revisionApi(patchSet).submit();
320 : }
321 :
322 0 : } catch (IllegalStateException | RestApiException e) {
323 0 : throw die(e);
324 2 : }
325 2 : }
326 :
327 : private ChangeApi changeApi(PatchSet patchSet) throws RestApiException {
328 1 : return gApi.changes().id(patchSet.id().changeId().get());
329 : }
330 :
331 : private RevisionApi revisionApi(PatchSet patchSet) throws RestApiException {
332 0 : return changeApi(patchSet).revision(patchSet.commitId().name());
333 : }
334 :
335 : @Override
336 : protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
337 2 : optionMap = new LinkedHashMap<>();
338 2 : customLabels = new HashMap<>();
339 :
340 : ProjectState allProjectsState;
341 : try {
342 2 : allProjectsState = projectCache.getAllProjects();
343 0 : } catch (Exception e) {
344 0 : throw die("missing " + allProjects.get(), e);
345 2 : }
346 :
347 2 : for (LabelType type : allProjectsState.getLabelTypes().getLabelTypes()) {
348 2 : StringBuilder usage = new StringBuilder("score for ").append(type.getName()).append("\n");
349 :
350 2 : for (LabelValue v : type.getValues()) {
351 2 : usage.append(v.format()).append("\n");
352 2 : }
353 :
354 2 : optionMap.put(newApproveOption(type, usage.toString()), new LabelSetter(type));
355 2 : }
356 :
357 2 : super.parseCommandLine(pluginOptions);
358 2 : }
359 :
360 : private static String asOptionName(LabelType type) {
361 2 : return "--" + type.getName().toLowerCase();
362 : }
363 :
364 : private static Option newApproveOption(LabelType type, String usage) {
365 2 : return OptionUtil.newOption(
366 2 : asOptionName(type),
367 2 : ImmutableList.of(),
368 : usage,
369 : "N",
370 : false,
371 : false,
372 : false,
373 : LabelHandler.class,
374 2 : ImmutableList.of(),
375 2 : ImmutableList.of());
376 : }
377 :
378 : private static class LabelSetter implements Setter<Short> {
379 : private final LabelType type;
380 : private Optional<Short> value;
381 :
382 2 : LabelSetter(LabelType type) {
383 2 : this.type = requireNonNull(type);
384 2 : this.value = Optional.empty();
385 2 : }
386 :
387 : Optional<Short> getValue() {
388 2 : return value;
389 : }
390 :
391 : LabelType getLabelType() {
392 2 : return type;
393 : }
394 :
395 : String getLabelName() {
396 1 : return type.getName();
397 : }
398 :
399 : @Override
400 : public void addValue(Short value) {
401 1 : this.value = Optional.of(value);
402 1 : }
403 :
404 : @Override
405 : public Class<Short> getType() {
406 0 : return Short.class;
407 : }
408 :
409 : @Override
410 : public boolean isMultiValued() {
411 0 : return false;
412 : }
413 :
414 : @Override
415 : public FieldSetter asFieldSetter() {
416 0 : throw new UnsupportedOperationException();
417 : }
418 :
419 : @Override
420 : public AnnotatedElement asAnnotatedElement() {
421 0 : throw new UnsupportedOperationException();
422 : }
423 : }
424 :
425 : public static class LabelHandler extends OneArgumentOptionHandler<Short> {
426 : private final LabelType type;
427 :
428 : public LabelHandler(
429 : org.kohsuke.args4j.CmdLineParser parser, OptionDef option, Setter<Short> setter) {
430 2 : super(parser, option, setter);
431 2 : this.type = ((LabelSetter) setter).getLabelType();
432 2 : }
433 :
434 : @Override
435 : protected Short parse(String token) throws NumberFormatException, CmdLineException {
436 1 : String argument = token;
437 1 : if (argument.startsWith("+")) {
438 0 : argument = argument.substring(1);
439 : }
440 :
441 1 : short value = Short.parseShort(argument);
442 1 : LabelValue min = type.getMin();
443 1 : LabelValue max = type.getMax();
444 :
445 1 : if (value < min.getValue() || value > max.getValue()) {
446 0 : String e =
447 : "\""
448 : + token
449 : + "\" must be in range "
450 0 : + min.formatValue()
451 : + ".."
452 0 : + max.formatValue()
453 : + " for \""
454 0 : + asOptionName(type)
455 : + "\"";
456 0 : throw new CmdLineException(owner, localizable(e));
457 : }
458 1 : return value;
459 : }
460 : }
461 : }
|