Line data Source code
1 : // Copyright (C) 2012 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.server.restapi.change;
16 :
17 : import static com.google.gerrit.git.ObjectIds.abbreviateName;
18 : import static com.google.gerrit.server.project.ProjectCache.illegalState;
19 : import static java.util.stream.Collectors.joining;
20 :
21 : import com.google.common.base.MoreObjects;
22 : import com.google.common.base.Strings;
23 : import com.google.common.collect.ImmutableMap;
24 : import com.google.common.collect.ListMultimap;
25 : import com.google.common.collect.Sets;
26 : import com.google.common.flogger.FluentLogger;
27 : import com.google.gerrit.common.Nullable;
28 : import com.google.gerrit.common.UsedAt;
29 : import com.google.gerrit.common.data.ParameterizedString;
30 : import com.google.gerrit.entities.BranchNameKey;
31 : import com.google.gerrit.entities.Change;
32 : import com.google.gerrit.entities.PatchSet;
33 : import com.google.gerrit.entities.Project;
34 : import com.google.gerrit.entities.SubmitTypeRecord;
35 : import com.google.gerrit.exceptions.StorageException;
36 : import com.google.gerrit.extensions.api.changes.SubmitInput;
37 : import com.google.gerrit.extensions.client.SubmitType;
38 : import com.google.gerrit.extensions.common.ChangeInfo;
39 : import com.google.gerrit.extensions.restapi.AuthException;
40 : import com.google.gerrit.extensions.restapi.ResourceConflictException;
41 : import com.google.gerrit.extensions.restapi.Response;
42 : import com.google.gerrit.extensions.restapi.RestApiException;
43 : import com.google.gerrit.extensions.restapi.RestModifyView;
44 : import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
45 : import com.google.gerrit.extensions.webui.UiAction;
46 : import com.google.gerrit.server.ChangeUtil;
47 : import com.google.gerrit.server.CurrentUser;
48 : import com.google.gerrit.server.IdentifiedUser;
49 : import com.google.gerrit.server.PatchSetUtil;
50 : import com.google.gerrit.server.ProjectUtil;
51 : import com.google.gerrit.server.account.AccountResolver;
52 : import com.google.gerrit.server.change.ChangeJson;
53 : import com.google.gerrit.server.change.ChangeResource;
54 : import com.google.gerrit.server.change.RevisionResource;
55 : import com.google.gerrit.server.config.GerritServerConfig;
56 : import com.google.gerrit.server.git.GitRepositoryManager;
57 : import com.google.gerrit.server.permissions.ChangePermission;
58 : import com.google.gerrit.server.permissions.PermissionBackend;
59 : import com.google.gerrit.server.permissions.PermissionBackendException;
60 : import com.google.gerrit.server.project.ProjectCache;
61 : import com.google.gerrit.server.project.ProjectState;
62 : import com.google.gerrit.server.query.change.ChangeData;
63 : import com.google.gerrit.server.query.change.InternalChangeQuery;
64 : import com.google.gerrit.server.submit.ChangeSet;
65 : import com.google.gerrit.server.submit.MergeOp;
66 : import com.google.gerrit.server.submit.MergeSuperSet;
67 : import com.google.gerrit.server.update.UpdateException;
68 : import com.google.inject.Inject;
69 : import com.google.inject.Provider;
70 : import com.google.inject.Singleton;
71 : import java.io.IOException;
72 : import java.util.Arrays;
73 : import java.util.Collection;
74 : import java.util.EnumSet;
75 : import java.util.HashMap;
76 : import java.util.HashSet;
77 : import java.util.Map;
78 : import java.util.Set;
79 : import java.util.stream.Collectors;
80 : import org.eclipse.jgit.errors.ConfigInvalidException;
81 : import org.eclipse.jgit.errors.RepositoryNotFoundException;
82 : import org.eclipse.jgit.lib.Config;
83 : import org.eclipse.jgit.lib.ObjectId;
84 : import org.eclipse.jgit.lib.Repository;
85 : import org.eclipse.jgit.revwalk.RevCommit;
86 : import org.eclipse.jgit.revwalk.RevWalk;
87 :
88 : @Singleton
89 : public class Submit
90 : implements RestModifyView<RevisionResource, SubmitInput>, UiAction<RevisionResource> {
91 145 : private static final FluentLogger logger = FluentLogger.forEnclosingClass();
92 :
93 : private static final String DEFAULT_TOOLTIP = "Submit patch set ${patchSet} into ${branch}";
94 : private static final String DEFAULT_TOOLTIP_ANCESTORS =
95 : "Submit patch set ${patchSet} and ancestors (${submitSize} changes "
96 : + "altogether) into ${branch}";
97 : private static final String DEFAULT_TOPIC_TOOLTIP =
98 : "Submit all ${topicSize} changes of the same topic "
99 : + "(${submitSize} changes including ancestors and other "
100 : + "changes related by topic)";
101 : private static final String BLOCKED_HIDDEN_SUBMIT_TOOLTIP =
102 : "This change depends on other hidden changes which are not ready";
103 : private static final String CLICK_FAILURE_TOOLTIP = "Clicking the button would fail";
104 : private static final String CHANGE_UNMERGEABLE = "Problems with integrating this change";
105 :
106 : private final GitRepositoryManager repoManager;
107 : private final PermissionBackend permissionBackend;
108 : private final Provider<MergeOp> mergeOpProvider;
109 : private final Provider<MergeSuperSet> mergeSuperSet;
110 : private final AccountResolver accountResolver;
111 : private final String label;
112 : private final String labelWithParents;
113 : private final ParameterizedString titlePattern;
114 : private final ParameterizedString titlePatternWithAncestors;
115 : private final String submitTopicLabel;
116 : private final ParameterizedString submitTopicTooltip;
117 : private final boolean submitWholeTopic;
118 : private final Provider<InternalChangeQuery> queryProvider;
119 : private final PatchSetUtil psUtil;
120 : private final ProjectCache projectCache;
121 : private final ChangeJson.Factory json;
122 :
123 : @Inject
124 : Submit(
125 : GitRepositoryManager repoManager,
126 : PermissionBackend permissionBackend,
127 : Provider<MergeOp> mergeOpProvider,
128 : Provider<MergeSuperSet> mergeSuperSet,
129 : AccountResolver accountResolver,
130 : @GerritServerConfig Config cfg,
131 : Provider<InternalChangeQuery> queryProvider,
132 : PatchSetUtil psUtil,
133 : ProjectCache projectCache,
134 145 : ChangeJson.Factory json) {
135 145 : this.repoManager = repoManager;
136 145 : this.permissionBackend = permissionBackend;
137 145 : this.mergeOpProvider = mergeOpProvider;
138 145 : this.mergeSuperSet = mergeSuperSet;
139 145 : this.accountResolver = accountResolver;
140 145 : this.label =
141 145 : MoreObjects.firstNonNull(
142 145 : Strings.emptyToNull(cfg.getString("change", null, "submitLabel")), "Submit");
143 145 : this.labelWithParents =
144 145 : MoreObjects.firstNonNull(
145 145 : Strings.emptyToNull(cfg.getString("change", null, "submitLabelWithParents")),
146 : "Submit including parents");
147 145 : this.titlePattern =
148 : new ParameterizedString(
149 145 : MoreObjects.firstNonNull(
150 145 : cfg.getString("change", null, "submitTooltip"), DEFAULT_TOOLTIP));
151 145 : this.titlePatternWithAncestors =
152 : new ParameterizedString(
153 145 : MoreObjects.firstNonNull(
154 145 : cfg.getString("change", null, "submitTooltipAncestors"),
155 : DEFAULT_TOOLTIP_ANCESTORS));
156 145 : submitWholeTopic = MergeSuperSet.wholeTopicEnabled(cfg);
157 145 : this.submitTopicLabel =
158 145 : MoreObjects.firstNonNull(
159 145 : Strings.emptyToNull(cfg.getString("change", null, "submitTopicLabel")),
160 : "Submit whole topic");
161 145 : this.submitTopicTooltip =
162 : new ParameterizedString(
163 145 : MoreObjects.firstNonNull(
164 145 : cfg.getString("change", null, "submitTopicTooltip"), DEFAULT_TOPIC_TOOLTIP));
165 145 : this.queryProvider = queryProvider;
166 145 : this.psUtil = psUtil;
167 145 : this.projectCache = projectCache;
168 145 : this.json = json;
169 145 : }
170 :
171 : @Override
172 : public Response<ChangeInfo> apply(RevisionResource rsrc, @Nullable SubmitInput input)
173 : throws RestApiException, RepositoryNotFoundException, IOException, PermissionBackendException,
174 : UpdateException, ConfigInvalidException {
175 46 : if (input == null) {
176 0 : input = new SubmitInput();
177 : }
178 46 : input.onBehalfOf = Strings.emptyToNull(input.onBehalfOf);
179 : IdentifiedUser submitter;
180 46 : if (input.onBehalfOf != null) {
181 2 : submitter = onBehalfOf(rsrc, input);
182 : } else {
183 45 : rsrc.permissions().check(ChangePermission.SUBMIT);
184 45 : submitter = rsrc.getUser().asIdentifiedUser();
185 : }
186 46 : projectCache
187 46 : .get(rsrc.getProject())
188 46 : .orElseThrow(illegalState(rsrc.getProject()))
189 46 : .checkStatePermitsWrite();
190 :
191 46 : return Response.ok(json.noOptions().format(mergeChange(rsrc, submitter, input)));
192 : }
193 :
194 : @UsedAt(UsedAt.Project.GOOGLE)
195 : public Change mergeChange(RevisionResource rsrc, IdentifiedUser submitter, SubmitInput input)
196 : throws RestApiException, IOException, UpdateException, ConfigInvalidException,
197 : PermissionBackendException {
198 46 : Change change = rsrc.getChange();
199 46 : if (!change.isNew()) {
200 0 : throw new ResourceConflictException("change is " + ChangeUtil.status(change));
201 46 : } else if (!ProjectUtil.branchExists(repoManager, change.getDest())) {
202 0 : throw new ResourceConflictException(
203 0 : String.format("destination branch \"%s\" not found.", change.getDest().branch()));
204 46 : } else if (!rsrc.getPatchSet().id().equals(change.currentPatchSetId())) {
205 : // TODO Allow submitting non-current revision by changing the current.
206 0 : throw new ResourceConflictException(
207 0 : String.format(
208 0 : "revision %s is not current revision", rsrc.getPatchSet().commitId().name()));
209 : }
210 :
211 46 : try (MergeOp op = mergeOpProvider.get()) {
212 : Change updatedChange;
213 :
214 46 : updatedChange = op.merge(change, submitter, true, input, false);
215 46 : if (updatedChange.isMerged()) {
216 46 : return updatedChange;
217 : }
218 :
219 0 : throw new IllegalStateException(
220 0 : String.format(
221 : "change %s of project %s unexpectedly had status %s after submit attempt",
222 0 : updatedChange.getId(), updatedChange.getProject(), updatedChange.getStatus()));
223 : }
224 : }
225 :
226 : /**
227 : * Returns a message describing what prevents the current change from being submitted - or null.
228 : * This method only considers parent changes, and changes in the same topic. The caller is
229 : * responsible for making sure the current change to be submitted can indeed be submitted
230 : * (permissions, submit rules, is not a WIP...)
231 : *
232 : * @param cd the change the user is currently looking at
233 : * @param cs set of changes to be submitted at once
234 : * @param user the user who is checking to submit
235 : * @return a reason why any of the changes is not submittable or null
236 : */
237 : @Nullable
238 : private String problemsForSubmittingChangeset(ChangeData cd, ChangeSet cs, CurrentUser user) {
239 : try {
240 18 : if (cs.furtherHiddenChanges()) {
241 0 : logger.atFine().log(
242 : "Change %d cannot be submitted by user %s because it depends on hidden changes: %s",
243 0 : cd.getId().get(), user.getLoggableName(), cs.nonVisibleChanges());
244 0 : return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
245 : }
246 18 : for (ChangeData c : cs.changes()) {
247 18 : Set<ChangePermission> can =
248 : permissionBackend
249 18 : .user(user)
250 18 : .change(c)
251 18 : .test(EnumSet.of(ChangePermission.READ, ChangePermission.SUBMIT));
252 18 : if (!can.contains(ChangePermission.READ)) {
253 0 : logger.atFine().log(
254 : "Change %d cannot be submitted by user %s because it depends on change %d which the user cannot read",
255 0 : cd.getId().get(), user.getLoggableName(), c.getId().get());
256 0 : return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
257 : }
258 18 : if (!can.contains(ChangePermission.SUBMIT)) {
259 2 : return "You don't have permission to submit change " + c.getId();
260 : }
261 18 : if (c.change().isWorkInProgress()) {
262 0 : return "Change " + c.getId() + " is marked work in progress";
263 : }
264 : try {
265 : // The data in the change index may be stale (e.g. if submit requirements have been
266 : // changed). For that one change for which the submit action is computed, use the
267 : // freshly loaded ChangeData instance 'cd' instead of the potentially stale ChangeData
268 : // instance 'c' that was loaded from the index. This makes a difference if the ChangeSet
269 : // 'cs' only contains this one single change. If the ChangeSet contains further changes
270 : // those may still be stale.
271 18 : MergeOp.checkSubmitRequirements(cd.getId().equals(c.getId()) ? cd : c);
272 1 : } catch (ResourceConflictException e) {
273 1 : return (c.getId() == cd.getId())
274 0 : ? String.format("Change %s is not ready: %s", cd.getId(), e.getMessage())
275 1 : : String.format(
276 : "Change %s must be submitted with change %s but %s is not ready: %s",
277 1 : cd.getId(), c.getId(), c.getId(), e.getMessage());
278 18 : }
279 18 : }
280 :
281 18 : Collection<ChangeData> unmergeable = unmergeableChanges(cs);
282 18 : if (unmergeable == null) {
283 0 : return CLICK_FAILURE_TOOLTIP;
284 18 : } else if (!unmergeable.isEmpty()) {
285 6 : for (ChangeData c : unmergeable) {
286 6 : if (c.change().getKey().equals(cd.change().getKey())) {
287 5 : return CHANGE_UNMERGEABLE;
288 : }
289 1 : }
290 :
291 1 : return "Problems with change(s): "
292 1 : + unmergeable.stream().map(c -> c.getId().toString()).collect(joining(", "));
293 : }
294 0 : } catch (PermissionBackendException | IOException e) {
295 0 : logger.atSevere().withCause(e).log("Error checking if change is submittable");
296 0 : throw new StorageException("Could not determine problems for the change", e);
297 18 : }
298 18 : return null;
299 : }
300 :
301 : @Nullable
302 : @Override
303 : public UiAction.Description getDescription(RevisionResource resource)
304 : throws IOException, PermissionBackendException {
305 57 : Change change = resource.getChange();
306 57 : if (!change.isNew() || !resource.isCurrent()) {
307 24 : return null; // submit not visible
308 : }
309 52 : if (!projectCache
310 52 : .get(resource.getProject())
311 52 : .map(ProjectState::statePermitsWrite)
312 52 : .orElse(false)) {
313 0 : return null; // submit not visible
314 : }
315 :
316 52 : ChangeData cd = resource.getChangeResource().getChangeData();
317 : try {
318 18 : MergeOp.checkSubmitRequirements(cd);
319 52 : } catch (ResourceConflictException e) {
320 52 : return null; // submit not visible
321 18 : }
322 :
323 18 : ChangeSet cs =
324 : mergeSuperSet
325 18 : .get()
326 18 : .completeChangeSet(cd.change(), resource.getUser(), /*includingTopicClosure= */ false);
327 18 : String topic = change.getTopic();
328 18 : int topicSize = 0;
329 18 : if (!Strings.isNullOrEmpty(topic)) {
330 9 : topicSize = queryProvider.get().noFields().byTopicOpen(topic).size();
331 : }
332 18 : boolean treatWithTopic = submitWholeTopic && !Strings.isNullOrEmpty(topic) && topicSize > 1;
333 :
334 18 : String submitProblems = problemsForSubmittingChangeset(cd, cs, resource.getUser());
335 :
336 18 : if (submitProblems != null) {
337 7 : return new UiAction.Description()
338 7 : .setLabel(treatWithTopic ? submitTopicLabel : (cs.size() > 1) ? labelWithParents : label)
339 7 : .setTitle(submitProblems)
340 7 : .setVisible(true)
341 7 : .setEnabled(false);
342 : }
343 :
344 : // Recheck mergeability rather than using value stored in the index, which may be stale.
345 : // TODO(dborowitz): This is ugly; consider providing a way to not read stored fields from the
346 : // index in the first place.
347 : // cd.setMergeable(null);
348 : // That was done in unmergeableChanges which was called by problemsForSubmittingChangeset, so
349 : // now it is safe to read from the cache, as it yields the same result.
350 18 : Boolean enabled = cd.isMergeable();
351 :
352 18 : if (treatWithTopic) {
353 7 : Map<String, String> params =
354 7 : ImmutableMap.of(
355 7 : "topicSize", String.valueOf(topicSize),
356 7 : "submitSize", String.valueOf(cs.size()));
357 7 : return new UiAction.Description()
358 7 : .setLabel(submitTopicLabel)
359 7 : .setTitle(Strings.emptyToNull(submitTopicTooltip.replace(params)))
360 7 : .setVisible(true)
361 7 : .setEnabled(Boolean.TRUE.equals(enabled));
362 : }
363 18 : Map<String, String> params =
364 18 : ImmutableMap.of(
365 18 : "patchSet", String.valueOf(resource.getPatchSet().number()),
366 18 : "branch", change.getDest().shortName(),
367 18 : "commit", abbreviateName(resource.getPatchSet().commitId()),
368 18 : "submitSize", String.valueOf(cs.size()));
369 18 : ParameterizedString tp = cs.size() > 1 ? titlePatternWithAncestors : titlePattern;
370 18 : return new UiAction.Description()
371 18 : .setLabel(cs.size() > 1 ? labelWithParents : label)
372 18 : .setTitle(Strings.emptyToNull(tp.replace(params)))
373 18 : .setVisible(true)
374 18 : .setEnabled(Boolean.TRUE.equals(enabled));
375 : }
376 :
377 : @Nullable
378 : public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws IOException {
379 19 : Set<ChangeData> mergeabilityMap = new HashSet<>();
380 19 : Set<ObjectId> outDatedPatchsets = new HashSet<>();
381 19 : for (ChangeData change : cs.changes()) {
382 19 : mergeabilityMap.add(change);
383 : // Add all the patchsets commit ids except the current patchset.
384 19 : outDatedPatchsets.addAll(
385 19 : change.notes().getPatchSets().values().stream()
386 19 : .map(p -> p.commitId())
387 19 : .collect(Collectors.toSet()));
388 19 : outDatedPatchsets.remove(change.currentPatchSet().commitId());
389 19 : }
390 :
391 19 : ListMultimap<BranchNameKey, ChangeData> cbb = cs.changesByBranch();
392 19 : for (BranchNameKey branch : cbb.keySet()) {
393 19 : Collection<ChangeData> targetBranch = cbb.get(branch);
394 19 : HashMap<Change.Id, RevCommit> commits = findCommits(targetBranch, branch.project());
395 :
396 19 : Set<ObjectId> allParents = Sets.newHashSetWithExpectedSize(cs.size());
397 19 : for (RevCommit commit : commits.values()) {
398 19 : for (RevCommit parent : commit.getParents()) {
399 15 : allParents.add(parent.getId());
400 : }
401 19 : }
402 19 : for (ChangeData change : targetBranch) {
403 :
404 19 : RevCommit commit = commits.get(change.getId());
405 19 : boolean isMergeCommit = commit.getParentCount() > 1;
406 19 : boolean isLastInChain = !allParents.contains(commit.getId());
407 19 : if (Arrays.stream(commit.getParents()).anyMatch(c -> outDatedPatchsets.contains(c.getId()))
408 5 : && !isCherryPickSubmit(change)) {
409 : // Found a parent that depends on an outdated patchset and the submit strategy is not
410 : // cherry-pick.
411 5 : continue;
412 : }
413 : // Recheck mergeability rather than using value stored in the index,
414 : // which may be stale.
415 : // TODO(dborowitz): This is ugly; consider providing a way to not read
416 : // stored fields from the index in the first place.
417 19 : change.setMergeable(null);
418 19 : Boolean mergeable = change.isMergeable();
419 19 : if (mergeable == null) {
420 : // Skip whole check, cannot determine if mergeable
421 0 : return null;
422 : }
423 19 : if (mergeable) {
424 19 : mergeabilityMap.remove(change);
425 : }
426 :
427 19 : if (isLastInChain && isMergeCommit && mergeable) {
428 7 : for (ChangeData c : targetBranch) {
429 7 : mergeabilityMap.remove(c);
430 7 : }
431 7 : break;
432 : }
433 19 : }
434 19 : }
435 19 : return mergeabilityMap;
436 : }
437 :
438 : private boolean isCherryPickSubmit(ChangeData changeData) {
439 5 : SubmitTypeRecord submitTypeRecord = changeData.submitTypeRecord();
440 5 : return submitTypeRecord.isOk() && submitTypeRecord.type == SubmitType.CHERRY_PICK;
441 : }
442 :
443 : private HashMap<Change.Id, RevCommit> findCommits(
444 : Collection<ChangeData> changes, Project.NameKey project) throws IOException {
445 19 : HashMap<Change.Id, RevCommit> commits = new HashMap<>();
446 19 : try (Repository repo = repoManager.openRepository(project);
447 19 : RevWalk walk = new RevWalk(repo)) {
448 19 : for (ChangeData change : changes) {
449 19 : RevCommit commit = walk.parseCommit(psUtil.current(change.notes()).commitId());
450 19 : commits.put(change.getId(), commit);
451 19 : }
452 : }
453 19 : return commits;
454 : }
455 :
456 : private IdentifiedUser onBehalfOf(RevisionResource rsrc, SubmitInput in)
457 : throws AuthException, UnprocessableEntityException, PermissionBackendException, IOException,
458 : ConfigInvalidException {
459 2 : PermissionBackend.ForChange perm = rsrc.permissions();
460 2 : perm.check(ChangePermission.SUBMIT);
461 2 : perm.check(ChangePermission.SUBMIT_AS);
462 :
463 2 : CurrentUser caller = rsrc.getUser();
464 2 : IdentifiedUser submitter =
465 2 : accountResolver.resolve(in.onBehalfOf).asUniqueUserOnBehalfOf(caller);
466 : try {
467 2 : permissionBackend.user(submitter).change(rsrc.getNotes()).check(ChangePermission.READ);
468 1 : } catch (AuthException e) {
469 1 : throw new UnprocessableEntityException(
470 1 : String.format("on_behalf_of account %s cannot see change", submitter.getAccountId()), e);
471 2 : }
472 2 : return submitter;
473 : }
474 :
475 : public static class CurrentRevision implements RestModifyView<ChangeResource, SubmitInput> {
476 : private final Submit submit;
477 : private final PatchSetUtil psUtil;
478 :
479 : @Inject
480 57 : CurrentRevision(Submit submit, PatchSetUtil psUtil) {
481 57 : this.submit = submit;
482 57 : this.psUtil = psUtil;
483 57 : }
484 :
485 : @Override
486 : public Response<ChangeInfo> apply(ChangeResource rsrc, SubmitInput input) throws Exception {
487 1 : PatchSet ps = psUtil.current(rsrc.getNotes());
488 1 : if (ps == null) {
489 0 : throw new ResourceConflictException("current revision is missing");
490 : }
491 :
492 1 : return submit.apply(new RevisionResource(rsrc, ps), input);
493 : }
494 : }
495 : }
|