| // Copyright (C) 2020 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.acceptance.testsuite.change; |
| |
| import static com.google.common.base.Preconditions.checkState; |
| import static com.google.common.collect.ImmutableList.toImmutableList; |
| |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.ImmutableSet; |
| import com.google.common.collect.Streams; |
| import com.google.gerrit.entities.Account; |
| import com.google.gerrit.entities.Change; |
| import com.google.gerrit.entities.PatchSet; |
| import com.google.gerrit.entities.Project; |
| import com.google.gerrit.entities.RefNames; |
| import com.google.gerrit.exceptions.StorageException; |
| import com.google.gerrit.extensions.restapi.BadRequestException; |
| import com.google.gerrit.extensions.restapi.RestApiException; |
| import com.google.gerrit.server.ChangeUtil; |
| import com.google.gerrit.server.GerritPersonIdent; |
| import com.google.gerrit.server.IdentifiedUser; |
| import com.google.gerrit.server.account.AccountResolver; |
| import com.google.gerrit.server.change.ChangeFinder; |
| import com.google.gerrit.server.change.ChangeInserter; |
| import com.google.gerrit.server.change.PatchSetInserter; |
| import com.google.gerrit.server.edit.tree.TreeCreator; |
| import com.google.gerrit.server.edit.tree.TreeModification; |
| import com.google.gerrit.server.git.GitRepositoryManager; |
| import com.google.gerrit.server.notedb.ChangeNotes; |
| import com.google.gerrit.server.notedb.Sequences; |
| import com.google.gerrit.server.project.ProjectCache; |
| import com.google.gerrit.server.update.BatchUpdate; |
| import com.google.gerrit.server.update.UpdateException; |
| import com.google.gerrit.server.util.CommitMessageUtil; |
| import com.google.gerrit.server.util.time.TimeUtil; |
| import com.google.inject.Inject; |
| import java.io.IOException; |
| import java.time.Instant; |
| import java.util.Arrays; |
| import java.util.Objects; |
| import java.util.Optional; |
| import org.eclipse.jgit.errors.ConfigInvalidException; |
| import org.eclipse.jgit.lib.AnyObjectId; |
| import org.eclipse.jgit.lib.CommitBuilder; |
| import org.eclipse.jgit.lib.Config; |
| 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.Repository; |
| import org.eclipse.jgit.merge.MergeStrategy; |
| import org.eclipse.jgit.merge.Merger; |
| import org.eclipse.jgit.revwalk.RevCommit; |
| import org.eclipse.jgit.revwalk.RevWalk; |
| import org.eclipse.jgit.util.ChangeIdUtil; |
| |
| /** |
| * The implementation of {@link ChangeOperations}. |
| * |
| * <p>There is only one implementation of {@link ChangeOperations}. Nevertheless, we keep the |
| * separation between interface and implementation to enhance clarity. |
| */ |
| public class ChangeOperationsImpl implements ChangeOperations { |
| private final Sequences seq; |
| private final ChangeInserter.Factory changeInserterFactory; |
| private final PatchSetInserter.Factory patchsetInserterFactory; |
| private final GitRepositoryManager repositoryManager; |
| private final AccountResolver resolver; |
| private final IdentifiedUser.GenericFactory userFactory; |
| private final PersonIdent serverIdent; |
| private final BatchUpdate.Factory batchUpdateFactory; |
| private final ProjectCache projectCache; |
| private final ChangeFinder changeFinder; |
| private final PerPatchsetOperationsImpl.Factory perPatchsetOperationsFactory; |
| private final PerCommentOperationsImpl.Factory perCommentOperationsFactory; |
| private final PerDraftCommentOperationsImpl.Factory perDraftCommentOperationsFactory; |
| private final PerRobotCommentOperationsImpl.Factory perRobotCommentOperationsFactory; |
| |
| @Inject |
| public ChangeOperationsImpl( |
| Sequences seq, |
| ChangeInserter.Factory changeInserterFactory, |
| PatchSetInserter.Factory patchsetInserterFactory, |
| GitRepositoryManager repositoryManager, |
| AccountResolver resolver, |
| IdentifiedUser.GenericFactory userFactory, |
| @GerritPersonIdent PersonIdent serverIdent, |
| BatchUpdate.Factory batchUpdateFactory, |
| ProjectCache projectCache, |
| ChangeFinder changeFinder, |
| PerPatchsetOperationsImpl.Factory perPatchsetOperationsFactory, |
| PerCommentOperationsImpl.Factory perCommentOperationsFactory, |
| PerDraftCommentOperationsImpl.Factory perDraftCommentOperationsFactory, |
| PerRobotCommentOperationsImpl.Factory perRobotCommentOperationsFactory) { |
| this.seq = seq; |
| this.changeInserterFactory = changeInserterFactory; |
| this.patchsetInserterFactory = patchsetInserterFactory; |
| this.repositoryManager = repositoryManager; |
| this.resolver = resolver; |
| this.userFactory = userFactory; |
| this.serverIdent = serverIdent; |
| this.batchUpdateFactory = batchUpdateFactory; |
| this.projectCache = projectCache; |
| this.changeFinder = changeFinder; |
| this.perPatchsetOperationsFactory = perPatchsetOperationsFactory; |
| this.perCommentOperationsFactory = perCommentOperationsFactory; |
| this.perDraftCommentOperationsFactory = perDraftCommentOperationsFactory; |
| this.perRobotCommentOperationsFactory = perRobotCommentOperationsFactory; |
| } |
| |
| @Override |
| public PerChangeOperations change(Change.Id changeId) { |
| return new PerChangeOperationsImpl(changeId); |
| } |
| |
| @Override |
| public TestChangeCreation.Builder newChange() { |
| return TestChangeCreation.builder(this::createChange); |
| } |
| |
| private Change.Id createChange(TestChangeCreation changeCreation) throws Exception { |
| Change.Id changeId = Change.id(seq.nextChangeId()); |
| Project.NameKey project = getTargetProject(changeCreation); |
| |
| try (Repository repository = repositoryManager.openRepository(project); |
| ObjectInserter objectInserter = repository.newObjectInserter(); |
| RevWalk revWalk = new RevWalk(objectInserter.newReader())) { |
| Instant now = TimeUtil.now(); |
| IdentifiedUser changeOwner = getChangeOwner(changeCreation); |
| PersonIdent authorAndCommitter = changeOwner.newCommitterIdent(now, serverIdent.getZoneId()); |
| ObjectId commitId = |
| createCommit(repository, revWalk, objectInserter, changeCreation, authorAndCommitter); |
| |
| String refName = RefNames.fullName(changeCreation.branch()); |
| ChangeInserter inserter = getChangeInserter(changeId, refName, commitId); |
| changeCreation.topic().ifPresent(t -> inserter.setTopic(t)); |
| inserter.setApprovals(changeCreation.approvals()); |
| |
| try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) { |
| batchUpdate.setRepository(repository, revWalk, objectInserter); |
| batchUpdate.insertChange(inserter); |
| batchUpdate.execute(); |
| } |
| return changeId; |
| } |
| } |
| |
| private Project.NameKey getTargetProject(TestChangeCreation changeCreation) { |
| if (changeCreation.project().isPresent()) { |
| return changeCreation.project().get(); |
| } |
| |
| return getArbitraryProject(); |
| } |
| |
| private Project.NameKey getArbitraryProject() { |
| Project.NameKey allProjectsName = projectCache.getAllProjects().getNameKey(); |
| Project.NameKey allUsersName = projectCache.getAllUsers().getNameKey(); |
| Optional<Project.NameKey> arbitraryProject = |
| projectCache.all().stream() |
| .filter( |
| name -> |
| !Objects.equals(name, allProjectsName) && !Objects.equals(name, allUsersName)) |
| .findFirst(); |
| checkState( |
| arbitraryProject.isPresent(), |
| "At least one repository must be available on the Gerrit server"); |
| return arbitraryProject.get(); |
| } |
| |
| private IdentifiedUser getChangeOwner(TestChangeCreation changeCreation) |
| throws IOException, ConfigInvalidException { |
| if (changeCreation.owner().isPresent()) { |
| return userFactory.create(changeCreation.owner().get()); |
| } |
| |
| return getArbitraryUser(); |
| } |
| |
| private IdentifiedUser getArbitraryUser() throws ConfigInvalidException, IOException { |
| ImmutableSet<Account.Id> foundAccounts = resolver.resolveIgnoreVisibility("").asIdSet(); |
| checkState( |
| !foundAccounts.isEmpty(), |
| "At least one user account must be available on the Gerrit server"); |
| return userFactory.create(foundAccounts.iterator().next()); |
| } |
| |
| private ObjectId createCommit( |
| Repository repository, |
| RevWalk revWalk, |
| ObjectInserter objectInserter, |
| TestChangeCreation changeCreation, |
| PersonIdent authorAndCommitter) |
| throws IOException, BadRequestException { |
| ImmutableList<ObjectId> parentCommits = getParentCommits(repository, revWalk, changeCreation); |
| TreeCreator treeCreator = |
| getTreeCreator(objectInserter, parentCommits, changeCreation.mergeStrategy()); |
| ObjectId tree = createNewTree(repository, treeCreator, changeCreation.treeModifications()); |
| String commitMessage = correctCommitMessage(changeCreation.commitMessage()); |
| return createCommit( |
| objectInserter, tree, parentCommits, authorAndCommitter, authorAndCommitter, commitMessage); |
| } |
| |
| private ImmutableList<ObjectId> getParentCommits( |
| Repository repository, RevWalk revWalk, TestChangeCreation changeCreation) { |
| |
| return changeCreation |
| .parents() |
| .map(parents -> resolveParents(repository, revWalk, parents)) |
| .orElseGet(() -> asImmutableList(getTip(repository, changeCreation.branch()))); |
| } |
| |
| private ImmutableList<ObjectId> resolveParents( |
| Repository repository, RevWalk revWalk, ImmutableList<TestCommitIdentifier> parents) { |
| return parents.stream() |
| .map(parent -> resolveCommit(repository, revWalk, parent)) |
| .collect(toImmutableList()); |
| } |
| |
| private ObjectId resolveCommit( |
| Repository repository, RevWalk revWalk, TestCommitIdentifier parentCommit) { |
| switch (parentCommit.getKind()) { |
| case BRANCH: |
| return resolveBranchTip(repository, parentCommit.branch()); |
| case CHANGE_ID: |
| return resolveChange(parentCommit.changeId()); |
| case COMMIT_SHA_1: |
| return resolveCommitFromSha1(revWalk, parentCommit.commitSha1()); |
| case PATCHSET_ID: |
| return resolvePatchset(parentCommit.patchsetId()); |
| default: |
| throw new IllegalStateException( |
| String.format("No parent behavior implemented for %s.", parentCommit.getKind())); |
| } |
| } |
| |
| private static ObjectId resolveBranchTip(Repository repository, String branchName) { |
| return getTip(repository, branchName) |
| .orElseThrow( |
| () -> |
| new IllegalStateException( |
| String.format( |
| "Tip of branch %s not found and hence can't be used as parent.", |
| branchName))); |
| } |
| |
| private static Optional<ObjectId> getTip(Repository repository, String branch) { |
| try { |
| Optional<Ref> ref = Optional.ofNullable(repository.findRef(branch)); |
| return ref.map(Ref::getObjectId); |
| } catch (IOException e) { |
| throw new StorageException(e); |
| } |
| } |
| |
| private ObjectId resolveChange(Change.Id changeId) { |
| Optional<ChangeNotes> changeNotes = changeFinder.findOne(changeId); |
| return changeNotes |
| .map(ChangeNotes::getCurrentPatchSet) |
| .map(PatchSet::commitId) |
| .orElseThrow( |
| () -> |
| new IllegalStateException( |
| String.format( |
| "Change %s not found and hence can't be used as parent.", changeId))); |
| } |
| |
| private static RevCommit resolveCommitFromSha1(RevWalk revWalk, ObjectId commitSha1) { |
| try { |
| return revWalk.parseCommit(commitSha1); |
| } catch (Exception e) { |
| throw new IllegalStateException( |
| String.format("Commit %s not found and hence can't be used as parent/base.", commitSha1), |
| e); |
| } |
| } |
| |
| private ObjectId resolvePatchset(PatchSet.Id patchsetId) { |
| Optional<ChangeNotes> changeNotes = changeFinder.findOne(patchsetId.changeId()); |
| return changeNotes |
| .map(ChangeNotes::getPatchSets) |
| .map(patchsets -> patchsets.get(patchsetId)) |
| .map(PatchSet::commitId) |
| .orElseThrow( |
| () -> |
| new IllegalStateException( |
| String.format( |
| "Patchset %s not found and hence can't be used as parent.", patchsetId))); |
| } |
| |
| private static <T> ImmutableList<T> asImmutableList(Optional<T> value) { |
| return Streams.stream(value).collect(toImmutableList()); |
| } |
| |
| private static TreeCreator getTreeCreator( |
| RevWalk revWalk, ObjectId customBaseCommit, ImmutableList<ObjectId> parentCommits) { |
| RevCommit commit = resolveCommitFromSha1(revWalk, customBaseCommit); |
| // Use actual parents; relevant for example when a file is restored (-> |
| // RestoreFileModification). |
| return TreeCreator.basedOnTree(commit.getTree(), parentCommits); |
| } |
| |
| private static TreeCreator getTreeCreator( |
| ObjectInserter objectInserter, |
| ImmutableList<ObjectId> parentCommits, |
| MergeStrategy mergeStrategy) { |
| if (parentCommits.isEmpty()) { |
| return TreeCreator.basedOnEmptyTree(); |
| } |
| ObjectId baseTreeId = merge(objectInserter, parentCommits, mergeStrategy); |
| return TreeCreator.basedOnTree(baseTreeId, parentCommits); |
| } |
| |
| private static ObjectId merge( |
| ObjectInserter objectInserter, |
| ImmutableList<ObjectId> parentCommits, |
| MergeStrategy mergeStrategy) { |
| try { |
| Merger merger = mergeStrategy.newMerger(objectInserter, new Config()); |
| boolean mergeSuccessful = merger.merge(parentCommits.toArray(new AnyObjectId[0])); |
| if (!mergeSuccessful) { |
| throw new IllegalStateException( |
| "Conflicts encountered while merging the specified parents. Use" |
| + " mergeOfButBaseOnFirst() instead to avoid these conflicts and define any" |
| + " other desired file contents with file().content()."); |
| } |
| return merger.getResultTreeId(); |
| } catch (IOException e) { |
| throw new IllegalStateException( |
| "Creating the merge commits of the specified parents failed for an unknown reason.", e); |
| } |
| } |
| |
| private static ObjectId createNewTree( |
| Repository repository, |
| TreeCreator treeCreator, |
| ImmutableList<TreeModification> treeModifications) |
| throws IOException { |
| treeCreator.addTreeModifications(treeModifications); |
| return treeCreator.createNewTreeAndGetId(repository); |
| } |
| |
| private String correctCommitMessage(String desiredCommitMessage) throws BadRequestException { |
| String commitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(desiredCommitMessage); |
| |
| if (ChangeIdUtil.indexOfChangeId(commitMessage, "\n") == -1) { |
| ObjectId id = CommitMessageUtil.generateChangeId(); |
| commitMessage = ChangeIdUtil.insertId(commitMessage, id); |
| } |
| |
| return commitMessage; |
| } |
| |
| private ObjectId createCommit( |
| ObjectInserter objectInserter, |
| ObjectId tree, |
| ImmutableList<ObjectId> parentCommitIds, |
| PersonIdent author, |
| PersonIdent committer, |
| String commitMessage) |
| throws IOException { |
| CommitBuilder builder = new CommitBuilder(); |
| builder.setTreeId(tree); |
| builder.setParentIds(parentCommitIds); |
| builder.setAuthor(author); |
| builder.setCommitter(committer); |
| builder.setMessage(commitMessage); |
| ObjectId newCommitId = objectInserter.insert(builder); |
| objectInserter.flush(); |
| return newCommitId; |
| } |
| |
| private ChangeInserter getChangeInserter(Change.Id changeId, String refName, ObjectId commitId) { |
| ChangeInserter inserter = changeInserterFactory.create(changeId, commitId, refName); |
| inserter.setMessage(String.format("Uploaded patchset %d.", inserter.getPatchSetId().get())); |
| return inserter; |
| } |
| |
| private class PerChangeOperationsImpl implements PerChangeOperations { |
| |
| private final Change.Id changeId; |
| |
| public PerChangeOperationsImpl(Change.Id changeId) { |
| this.changeId = changeId; |
| } |
| |
| @Override |
| public boolean exists() { |
| return changeFinder.findOne(changeId).isPresent(); |
| } |
| |
| @Override |
| public TestChange get() { |
| return toTestChange(getChangeNotes().getChange()); |
| } |
| |
| private ChangeNotes getChangeNotes() { |
| Optional<ChangeNotes> changeNotes = changeFinder.findOne(changeId); |
| checkState(changeNotes.isPresent(), "Tried to get non-existing test change."); |
| return changeNotes.get(); |
| } |
| |
| private TestChange toTestChange(Change change) { |
| return TestChange.builder() |
| .numericChangeId(change.getId()) |
| .changeId(change.getKey().get()) |
| .build(); |
| } |
| |
| @Override |
| public TestPatchsetCreation.Builder newPatchset() { |
| return TestPatchsetCreation.builder(this::createPatchset); |
| } |
| |
| private PatchSet.Id createPatchset(TestPatchsetCreation patchsetCreation) |
| throws IOException, RestApiException, UpdateException { |
| ChangeNotes changeNotes = getChangeNotes(); |
| Project.NameKey project = changeNotes.getProjectName(); |
| try (Repository repository = repositoryManager.openRepository(project); |
| ObjectInserter objectInserter = repository.newObjectInserter(); |
| RevWalk revWalk = new RevWalk(objectInserter.newReader())) { |
| Instant now = TimeUtil.now(); |
| ObjectId newPatchsetCommit = |
| createPatchsetCommit( |
| repository, revWalk, objectInserter, changeNotes, patchsetCreation, now); |
| |
| PatchSet.Id patchsetId = |
| ChangeUtil.nextPatchSetId(repository, changeNotes.getCurrentPatchSet().id()); |
| PatchSetInserter patchSetInserter = |
| getPatchSetInserter(changeNotes, newPatchsetCommit, patchsetId); |
| |
| IdentifiedUser changeOwner = userFactory.create(changeNotes.getChange().getOwner()); |
| try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) { |
| batchUpdate.setRepository(repository, revWalk, objectInserter); |
| batchUpdate.addOp(changeId, patchSetInserter); |
| batchUpdate.execute(); |
| } |
| return patchsetId; |
| } |
| } |
| |
| private ObjectId createPatchsetCommit( |
| Repository repository, |
| RevWalk revWalk, |
| ObjectInserter objectInserter, |
| ChangeNotes changeNotes, |
| TestPatchsetCreation patchsetCreation, |
| Instant now) |
| throws IOException, BadRequestException { |
| ObjectId oldPatchsetCommitId = changeNotes.getCurrentPatchSet().commitId(); |
| RevCommit oldPatchsetCommit = repository.parseCommit(oldPatchsetCommitId); |
| |
| ImmutableList<ObjectId> parentCommitIds = |
| getParents(repository, revWalk, patchsetCreation, oldPatchsetCommit); |
| TreeCreator treeCreator = getTreeCreator(revWalk, oldPatchsetCommit, parentCommitIds); |
| ObjectId tree = createNewTree(repository, treeCreator, patchsetCreation.treeModifications()); |
| |
| String commitMessage = |
| correctCommitMessage( |
| changeNotes.getChange().getKey().get(), |
| patchsetCreation.commitMessage().orElseGet(oldPatchsetCommit::getFullMessage)); |
| |
| PersonIdent author = getAuthor(oldPatchsetCommit); |
| PersonIdent committer = getCommitter(oldPatchsetCommit, now); |
| return createCommit(objectInserter, tree, parentCommitIds, author, committer, commitMessage); |
| } |
| |
| private String correctCommitMessage(String oldChangeId, String desiredCommitMessage) |
| throws BadRequestException { |
| String commitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(desiredCommitMessage); |
| |
| // Remove initial 'I' and treat the rest as ObjectId. This is not the cleanest approach but |
| // unfortunately, we don't seem to have other utility code which takes the string-based |
| // change-id and ensures that it is part of the commit message. |
| ObjectId id = ObjectId.fromString(oldChangeId.substring(1)); |
| commitMessage = ChangeIdUtil.insertId(commitMessage, id, false); |
| |
| return commitMessage; |
| } |
| |
| private PersonIdent getAuthor(RevCommit oldPatchsetCommit) { |
| return Optional.ofNullable(oldPatchsetCommit.getAuthorIdent()).orElse(serverIdent); |
| } |
| |
| private PersonIdent getCommitter(RevCommit oldPatchsetCommit, Instant now) { |
| PersonIdent oldPatchsetCommitter = |
| Optional.ofNullable(oldPatchsetCommit.getCommitterIdent()).orElse(serverIdent); |
| if (asSeconds(now) == asSeconds(oldPatchsetCommitter.getWhenAsInstant())) { |
| /* We need to ensure that the resulting commit SHA-1 is different from the old patchset. |
| * In real situations, this automatically happens as two patchsets won't have exactly the |
| * same commit timestamp even when the tree and commit message are the same. In tests, |
| * we can easily end up with the same timestamp as Git uses second precision for timestamps. |
| * We could of course require that tests must use TestTimeUtil#setClockStep but |
| * that would be an unnecessary nuisance for test writers. Hence, go with a simple solution |
| * here and simply add a second. */ |
| now = now.plusSeconds(1); |
| } |
| return new PersonIdent(oldPatchsetCommitter, now); |
| } |
| |
| private long asSeconds(Instant date) { |
| return date.getEpochSecond(); |
| } |
| |
| private ImmutableList<ObjectId> getParents( |
| Repository repository, |
| RevWalk revWalk, |
| TestPatchsetCreation patchsetCreation, |
| RevCommit oldPatchsetCommit) { |
| return patchsetCreation |
| .parents() |
| .map(parents -> resolveParents(repository, revWalk, parents)) |
| .orElseGet( |
| () -> Arrays.stream(oldPatchsetCommit.getParents()).collect(toImmutableList())); |
| } |
| |
| private PatchSetInserter getPatchSetInserter( |
| ChangeNotes changeNotes, ObjectId newPatchsetCommit, PatchSet.Id patchsetId) { |
| PatchSetInserter patchSetInserter = |
| patchsetInserterFactory.create(changeNotes, patchsetId, newPatchsetCommit); |
| patchSetInserter.setCheckAddPatchSetPermission(false); |
| patchSetInserter.setMessage(String.format("Uploaded patchset %d.", patchsetId.get())); |
| return patchSetInserter; |
| } |
| |
| @Override |
| public PerPatchsetOperations patchset(PatchSet.Id patchsetId) { |
| return perPatchsetOperationsFactory.create(getChangeNotes(), patchsetId); |
| } |
| |
| @Override |
| public PerPatchsetOperations currentPatchset() { |
| ChangeNotes changeNotes = getChangeNotes(); |
| return perPatchsetOperationsFactory.create( |
| changeNotes, changeNotes.getChange().currentPatchSetId()); |
| } |
| |
| @Override |
| public PerCommentOperations comment(String commentUuid) { |
| ChangeNotes changeNotes = getChangeNotes(); |
| return perCommentOperationsFactory.create(changeNotes, commentUuid); |
| } |
| |
| @Override |
| public PerDraftCommentOperations draftComment(String commentUuid) { |
| ChangeNotes changeNotes = getChangeNotes(); |
| return perDraftCommentOperationsFactory.create(changeNotes, commentUuid); |
| } |
| |
| @Override |
| public PerRobotCommentOperations robotComment(String commentUuid) { |
| ChangeNotes changeNotes = getChangeNotes(); |
| return perRobotCommentOperationsFactory.create(changeNotes, commentUuid); |
| } |
| } |
| } |