diff --git a/docs/reference.md b/docs/reference.md index cdf88b60e..01d669619 100755 --- a/docs/reference.md +++ b/docs/reference.md @@ -2240,7 +2240,7 @@ Name | Type | Description Creates a commit in a git repository using the transformed worktree.

For GitHub use git.github_destination. For creating Pull Requests in GitHub, use git.github_pr_destination. For creating a Gerrit change use git.gerrit_destination.

Given that Copybara doesn't ask for user/password in the console when doing the push to remote repos, you have to use ssh protocol, have the credentials cached or use a credential manager. -`destination` `git.destination(url, push='master', tag_name=None, tag_msg=None, fetch=None, partial_fetch=False, integrates=None, primary_branch_migration=False, checker=None)` +`destination` `git.destination(url, push='master', tag_name=None, tag_msg=None, fetch=None, partial_fetch=False, integrates=None, primary_branch_migration=False, checker=None, push_options=None)` #### Parameters: @@ -2256,6 +2256,7 @@ partial_fetch | `bool`

This is an experimental feature that only works for integrates | `sequence of git_integrate` or `NoneType`

Integrate changes from a url present in the migrated change label. Defaults to a semi-fake merge if COPYBARA_INTEGRATE_REVIEW label is present in the message

primary_branch_migration | `bool`

When enabled, copybara will ignore the 'push' and 'fetch' params if either is 'master' or 'main' and instead try to establish the default git branch. If this fails, it will fall back to the param's declared value.
This is intended to help migrating to the new standard of using 'main' without breaking users relying on the legacy default.

checker | `checker` or `NoneType`

A checker that can check leaks or other checks in the commit created.

+push_options | `sequence` or `NoneType`

A sequence of git push options that can pass into push command. Defaults to none which represents no push options.

diff --git a/java/com/google/copybara/ModuleSupplier.java b/java/com/google/copybara/ModuleSupplier.java index c2fa01f89..e9a8665ae 100644 --- a/java/com/google/copybara/ModuleSupplier.java +++ b/java/com/google/copybara/ModuleSupplier.java @@ -33,6 +33,8 @@ import com.google.copybara.git.GitHubDestinationOptions; import com.google.copybara.git.GitHubOptions; import com.google.copybara.git.GitHubPrOriginOptions; +import com.google.copybara.git.GitLabDestinationOptions; +import com.google.copybara.git.GitLabOptions; import com.google.copybara.git.GitMirrorOptions; import com.google.copybara.git.GitModule; import com.google.copybara.git.GitOptions; @@ -129,6 +131,8 @@ protected Options newOptions() { new GitHubOptions(generalOptions, gitOptions), new GitHubDestinationOptions(), new GerritOptions(generalOptions, gitOptions), + new GitLabOptions(generalOptions, gitOptions), + new GitLabDestinationOptions(), new GitMirrorOptions(), new HgOptions(generalOptions), new HgOriginOptions(), diff --git a/java/com/google/copybara/Origin.java b/java/com/google/copybara/Origin.java index 41eeda1aa..5550dd121 100644 --- a/java/com/google/copybara/Origin.java +++ b/java/com/google/copybara/Origin.java @@ -317,7 +317,7 @@ default Optional> findBaseline(R startRevision, String label) */ default ImmutableList findBaselinesWithoutLabel(R startRevision, int limit) throws RepoException, ValidationException { - throw new ValidationException("Origin does't support this workflow mode"); + throw new ValidationException("Origin doesn't support this workflow mode"); } class FindLatestWithLabel implements ChangesVisitor { diff --git a/java/com/google/copybara/git/BUILD b/java/com/google/copybara/git/BUILD index b2d015248..3978c1ff2 100644 --- a/java/com/google/copybara/git/BUILD +++ b/java/com/google/copybara/git/BUILD @@ -53,6 +53,8 @@ java_library( "//java/com/google/copybara/exception", "//java/com/google/copybara/git/github:api", "//java/com/google/copybara/git/github:util", + "//java/com/google/copybara/git/gitlab:api", + "//java/com/google/copybara/git/gitlab:util", "//java/com/google/copybara/jcommander:validators", "//java/com/google/copybara/monitor", "//java/com/google/copybara/profiler", diff --git a/java/com/google/copybara/git/GerritDestination.java b/java/com/google/copybara/git/GerritDestination.java index cc5a31846..bb1acc8e8 100644 --- a/java/com/google/copybara/git/GerritDestination.java +++ b/java/com/google/copybara/git/GerritDestination.java @@ -615,7 +615,8 @@ static GerritDestination newGerritDestination( gerritSubmit, primaryBranchMigrationMode), integrates, - checker), + checker, + ImmutableList.of()), submit); } diff --git a/java/com/google/copybara/git/GitDestination.java b/java/com/google/copybara/git/GitDestination.java index f0225595f..676906a96 100644 --- a/java/com/google/copybara/git/GitDestination.java +++ b/java/com/google/copybara/git/GitDestination.java @@ -110,6 +110,7 @@ static class MessageInfo { @Nullable private String resolvedPrimary = null; private final Iterable integrates; private final WriteHook writerHook; + private final Iterable pushOptions; @Nullable private final Checker checker; private final LazyResourceLoader localRepo; @@ -126,7 +127,8 @@ static class MessageInfo { GeneralOptions generalOptions, WriteHook writerHook, Iterable integrates, - @Nullable Checker checker) { + @Nullable Checker checker, + Iterable pushOptions) { this.repoUrl = checkNotNull(repoUrl); this.fetch = checkNotNull(fetch); this.push = checkNotNull(push); @@ -140,6 +142,7 @@ static class MessageInfo { this.integrates = checkNotNull(integrates); this.writerHook = checkNotNull(writerHook); this.checker = checker; + this.pushOptions = checkNotNull(pushOptions); this.localRepo = memoized(ignored -> destinationOptions.localGitRepo(repoUrl)); } @@ -191,7 +194,8 @@ public Writer newWriter(WriterContext writerContext) throws Validat destinationOptions.rebaseWhenBaseline(), gitOptions.visitChangePageSize, gitOptions.gitTagOverwrite, - checker); + checker, + pushOptions); } /** @@ -242,6 +246,7 @@ public static class WriterImpl private final int visitChangePageSize; private final boolean gitTagOverwrite; @Nullable private final Checker checker; + private final Iterable pushOptions; /** Create a new git.destination writer */ WriterImpl( @@ -265,7 +270,8 @@ public static class WriterImpl boolean rebase, int visitChangePageSize, boolean gitTagOverwrite, - Checker checker) { + Checker checker, + Iterable pushOptions) { this.skipPush = skipPush; this.repoUrl = checkNotNull(repoUrl); this.remoteFetch = checkNotNull(remoteFetch); @@ -289,6 +295,7 @@ public static class WriterImpl this.visitChangePageSize = visitChangePageSize; this.gitTagOverwrite = gitTagOverwrite; this.checker = checker; + this.pushOptions = pushOptions; } @Override @@ -659,6 +666,7 @@ public ImmutableList write(TransformResult transformResult, String serverResponse = generalOptions.repoTask( "push", () -> scratchClone.push() + .pushOptions(pushOptions) .withRefspecs(repoUrl, tagName != null ? ImmutableList.of(scratchClone.createRefSpec( diff --git a/java/com/google/copybara/git/GitHubPrDestination.java b/java/com/google/copybara/git/GitHubPrDestination.java index 4581dbdfc..1d267fda5 100644 --- a/java/com/google/copybara/git/GitHubPrDestination.java +++ b/java/com/google/copybara/git/GitHubPrDestination.java @@ -194,7 +194,8 @@ public Writer newWriter(WriterContext writerContext) throws Validat destinationOptions.rebaseWhenBaseline(), gitOptions.visitChangePageSize, gitOptions.gitTagOverwrite, - checker) { + checker, + ImmutableList.of()) { @Override public ImmutableList write( TransformResult transformResult, Glob destinationFiles, Console console) diff --git a/java/com/google/copybara/git/GitLabDestinationOptions.java b/java/com/google/copybara/git/GitLabDestinationOptions.java new file mode 100644 index 000000000..f968d7937 --- /dev/null +++ b/java/com/google/copybara/git/GitLabDestinationOptions.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2022 Google Inc. + * + * 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.copybara.git; + +import com.beust.jcommander.Parameter; +import com.beust.jcommander.Parameters; +import com.google.copybara.Option; + +/** + * Options related to GitLab destination + * + *

Intentionally empty so that we have the necessary infrastructure when + * we add gitlab options. + */ +@Parameters(separators = "=") +public final class GitLabDestinationOptions implements Option { + + static final String GITLAB_DESTINATION_MR_BRANCH = "--gitlab-destination-mr-branch"; + + @Parameter(names = GITLAB_DESTINATION_MR_BRANCH, + description = "If set, uses this branch for creating the merge request instead of using a" + + " generated one") + public String destinationMrBranch = null; + + @Parameter(names = "--gitlab-destination-mr-create", + description = "If the merge request should be created", arity = 1) + public boolean createMergeRequest = true; + +} diff --git a/java/com/google/copybara/git/GitLabMrDestination.java b/java/com/google/copybara/git/GitLabMrDestination.java new file mode 100644 index 000000000..13065d1b5 --- /dev/null +++ b/java/com/google/copybara/git/GitLabMrDestination.java @@ -0,0 +1,375 @@ +/* + * Copyright (C) 2022 Google Inc. + * + * 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.copybara.git; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Function; +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.flogger.FluentLogger; +import com.google.copybara.ChangeMessage; +import com.google.copybara.Destination; +import com.google.copybara.Endpoint; +import com.google.copybara.GeneralOptions; +import com.google.copybara.LazyResourceLoader; +import com.google.copybara.TransformResult; +import com.google.copybara.WriterContext; +import com.google.copybara.checks.Checker; +import com.google.copybara.config.ConfigFile; +import com.google.copybara.config.SkylarkUtil; +import com.google.copybara.effect.DestinationEffect; +import com.google.copybara.exception.RepoException; +import com.google.copybara.exception.ValidationException; +import com.google.copybara.git.github.util.GitHubUtil; +import com.google.copybara.git.gitlab.api.GitLabApi; +import com.google.copybara.git.gitlab.api.MergeRequest; +import com.google.copybara.git.gitlab.util.GitLabHost; +import com.google.copybara.revision.Revision; +import com.google.copybara.templatetoken.LabelTemplate; +import com.google.copybara.util.Glob; +import com.google.copybara.util.Identity; +import com.google.copybara.util.console.Console; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.util.UUID; + +import static com.google.copybara.LazyResourceLoader.memoized; +import static com.google.copybara.exception.ValidationException.checkCondition; +import static com.google.copybara.git.GitModule.PRIMARY_BRANCHES; + +public class GitLabMrDestination implements Destination { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private final String url; + private final String destinationRef; + private final String prBranch; + private final boolean partialFetch; + private final boolean primaryBranchMigrationMode; + + private final GeneralOptions generalOptions; + private final GitLabOptions gitLabOptions; + private final GitDestinationOptions destinationOptions; + private final GitLabDestinationOptions gitLabDestinationOptions; + private final GitOptions gitOptions; + private final GitLabMrWriteHook writeHook; + private final Iterable integrates; + @Nullable + private final String title; + @Nullable + private final String body; + private final boolean updateDescription; + private final GitLabHost glHost; + @Nullable + private final Checker checker; + private final LazyResourceLoader localRepo; + private final ConfigFile mainConfigFile; + @Nullable + private final Checker endpointChecker; + + @Nullable + private String resolvedDestinationRef; + + GitLabMrDestination( + String url, + String destinationRef, + @Nullable String prBranch, + boolean partialFetch, + GeneralOptions generalOptions, + GitLabOptions gitLabOptions, + GitDestinationOptions destinationOptions, + GitLabDestinationOptions gitLabDestinationOptions, + GitOptions gitOptions, + GitLabMrWriteHook writeHook, + Iterable integrates, + @Nullable String title, + @Nullable String body, + ConfigFile mainConfigFile, + @Nullable Checker endpointChecker, + boolean updateDescription, + GitLabHost glHost, + boolean primaryBranchMigrationMode, + @Nullable Checker checker) { + this.url = Preconditions.checkNotNull(url); + this.destinationRef = Preconditions.checkNotNull(destinationRef); + this.prBranch = prBranch; + this.partialFetch = partialFetch; + this.generalOptions = Preconditions.checkNotNull(generalOptions); + this.gitLabOptions = Preconditions.checkNotNull(gitLabOptions); + this.destinationOptions = Preconditions.checkNotNull(destinationOptions); + this.gitLabDestinationOptions = Preconditions.checkNotNull(gitLabDestinationOptions); + this.gitOptions = Preconditions.checkNotNull(gitOptions); + this.writeHook = Preconditions.checkNotNull(writeHook); + this.integrates = Preconditions.checkNotNull(integrates); + this.title = title; + this.body = body; + this.updateDescription = updateDescription; + this.glHost = Preconditions.checkNotNull(glHost); + this.checker = checker; + this.localRepo = memoized(ignored -> destinationOptions.localGitRepo(url)); + this.mainConfigFile = Preconditions.checkNotNull(mainConfigFile); + this.endpointChecker = endpointChecker; + this.primaryBranchMigrationMode = primaryBranchMigrationMode; + } + + @Override + public Writer newWriter(WriterContext writerContext) throws ValidationException { + String prBranch = + getMergeRequestBranchName( + writerContext.getOriginalRevision(), + writerContext.getWorkflowName(), + writerContext.getWorkflowIdentityUser()); + GitLabMrWriteHook gitLabMrWriteHook = writeHook.withUpdatedMrBranch(prBranch); + + GitLabWriterState state = new GitLabWriterState( + localRepo, + destinationOptions.localRepoPath != null + ? prBranch + : "copybara/push-" + + UUID.randomUUID() + + (writerContext.isDryRun() ? "-dryrun" : "")); + + return new GitDestination.WriterImpl( + writerContext.isDryRun(), + url, + getDestinationRef(), + prBranch, + partialFetch, + /*tagName*/ null, + /*tagMsg*/ null, + generalOptions, + gitLabMrWriteHook, + state, + /*nonFastForwardPush=*/ true, + integrates, + destinationOptions.lastRevFirstParent, + destinationOptions.ignoreIntegrationErrors, + destinationOptions.localRepoPath, + destinationOptions.committerName, + destinationOptions.committerEmail, + destinationOptions.rebaseWhenBaseline(), + gitOptions.visitChangePageSize, + gitOptions.gitTagOverwrite, + checker, + ImmutableList.of() + ) { + @Override + public ImmutableList write( + TransformResult transformResult, Glob destinationFiles, Console console) + throws ValidationException, RepoException, IOException { + ImmutableList.Builder result = + ImmutableList.builder() + .addAll(super.write(transformResult, destinationFiles, console)); + if (writerContext.isDryRun() || state.mergeRequestNumber != null) { + return result.build(); + } + + if (!gitLabDestinationOptions.createMergeRequest) { + console.infoFmt( + "Please create a MR manually following this link: %s/compare/%s...%s" + + " (Only needed once)", + asHttpsUrl(), getDestinationRef(), prBranch); + state.mergeRequestNumber = -1L; + return result.build(); + } + + GitLabApi api = gitLabOptions.newGitLabApi(); + + console.infoFmt("Search MRs for %s", prBranch); + + ImmutableList mergeRequests = + api.getMergeRequests(getProjectName(), prBranch); + + ChangeMessage msg = ChangeMessage.parseMessage(transformResult.getSummary().trim()); + + String title = + GitLabMrDestination.this.title == null + ? msg.firstLine() + : SkylarkUtil.mapLabels( + transformResult.getLabelFinder(), GitLabMrDestination.this.title, "title"); + + String mrBody = + GitLabMrDestination.this.body == null + ? msg.toString() + : SkylarkUtil.mapLabels( + transformResult.getLabelFinder(), GitLabMrDestination.this.body, "body"); + + + for (MergeRequest mr : mergeRequests) { + if (mr.getSourceBranch().equals(prBranch)) { + if (!mr.isOpen()) { + console.warnFmt( + "Merge request for branch %s already exists as %s/-/merge_requests/%s, but is closed - " + + "reopening.", + prBranch, asHttpsUrl(), mr.getNumber()); + api.updateMergeRequest( + getProjectName(), mr.getNumber(), title, mrBody, "reopen"); + } else { + console.infoFmt( + "Merge request for branch %s already exists as %s/-/merge_requests/%s", + prBranch, asHttpsUrl(), mr.getNumber()); + } + if (!mr.getTargetBranch().equals(getDestinationRef())) { + // TODO(malcon): Update MR or create a new one? + console.warnFmt( + "Current base branch '%s' is different from the MR base branch '%s'", + getDestinationRef(), mr.getTargetBranch()); + } + if (updateDescription) { + checkCondition( + !Strings.isNullOrEmpty(title), + "Merge Request title cannot be empty. Either use 'title' field in" + + " git.gitlab_mr_destination or modify the message to not be empty"); + api.updateMergeRequest( + getProjectName(), + mr.getNumber(), + title, mrBody, null); + } + result.add( + new DestinationEffect( + DestinationEffect.Type.UPDATED, + String.format("Merge Request %s updated", mr.getHtmlUrl()), + transformResult.getChanges().getCurrent(), + new DestinationEffect.DestinationRef( + Long.toString(mr.getNumber()), "pull_request", mr.getHtmlUrl()))); + return result.build(); + } + } + + checkCondition( + !Strings.isNullOrEmpty(title), + "Pull Request title cannot be empty. Either use 'title' field in" + + " git.github_pr_destination or modify the message to not be empty"); + + MergeRequest mr = + api.createMergeRequest( + getProjectName(), + title, mrBody, prBranch, getDestinationRef()); + console.infoFmt( + "Merge Request %s/-/merge_requests/%s created using branch '%s'.", + asHttpsUrl(), mr.getNumber(), prBranch); + state.mergeRequestNumber = mr.getNumber(); + result.add( + new DestinationEffect( + DestinationEffect.Type.CREATED, + String.format("Merge Request %s created", mr.getHtmlUrl()), + transformResult.getChanges().getCurrent(), + new DestinationEffect.DestinationRef( + Long.toString(mr.getNumber()), "merge_request", mr.getHtmlUrl()))); + return result.build(); + } + + @Override + public Endpoint getFeedbackEndPoint(Console console) throws ValidationException { + gitLabOptions.validateEndpointChecker(endpointChecker); + // do not provide any feedback endpoint as for now + // consider to enhance it later + return Endpoint.NOOP_ENDPOINT; + } + }; + } + + private String getMergeRequestBranchName( + @Nullable Revision changeRevision, String workflowName, String workflowIdentityUser) + throws ValidationException { + if (!Strings.isNullOrEmpty(gitLabDestinationOptions.destinationMrBranch)) { + return gitLabDestinationOptions.destinationMrBranch; + } + String contextReference = changeRevision.contextReference(); + String contextRevision = changeRevision.asString(); + // We could do more magic here with the change identity. But this is already complex so we + // require a group identity either provided by the origin or the workflow (Will be implemented + // later. + checkCondition(contextReference != null, + "git.gitlab_mr_destination is incompatible with the current origin. Origin has to be" + + " able to provide the contextReference or use '%s' flag", + GitLabDestinationOptions.GITLAB_DESTINATION_MR_BRANCH); + String branchNameFromUser = getCustomBranchName(contextReference, contextRevision); + String branchName = + branchNameFromUser != null + ? branchNameFromUser + : Identity.computeIdentity( + "OriginGroupIdentity", + contextReference, + workflowName, + mainConfigFile.getIdentifier(), + workflowIdentityUser); + return GitHubUtil.getValidBranchName(branchName); + } + + private String asHttpsUrl() throws ValidationException { + return gitLabOptions.getGitlabUrl() + "/" + getProjectName(); + } + + @VisibleForTesting + String getProjectName() throws ValidationException { + return glHost.getProjectNameFromUrl(url); + } + + @Override + public String getLabelNameWhenOrigin() { + return GitRepository.GIT_ORIGIN_REV_ID; + } + + private String getCustomBranchName(String contextReference, String revision) throws ValidationException { + if (prBranch == null) { + return null; + } + try { + return new LabelTemplate(prBranch) + .resolve((Function) e -> { + if (e.equals("CONTEXT_REFERENCE")) { + return contextReference; + } else if (e.equals("GITLAB_REVISION")) { + return revision; + } + return prBranch; + }); + } catch (LabelTemplate.LabelNotFoundException e) { + throw new ValidationException( + "Cannot find some labels in the GitLab MR branch name field: " + e.getMessage(), e); + } + } + + private static class GitLabWriterState extends GitDestination.WriterState { + + @Nullable + Long mergeRequestNumber; + + GitLabWriterState(LazyResourceLoader localRepo, String localBranch) { + super(localRepo, localBranch); + } + } + + @Nullable + String getDestinationRef() throws ValidationException { + if (!primaryBranchMigrationMode || !PRIMARY_BRANCHES.contains(destinationRef)) { + return destinationRef; + } + if (resolvedDestinationRef == null) { + try { + resolvedDestinationRef = localRepo.load(generalOptions.console()).getPrimaryBranch(url); + } catch (RepoException e) { + generalOptions.console().warnFmt("Error detecting primary branch: %s", e); + return null; + } + } + return resolvedDestinationRef; + } + +} diff --git a/java/com/google/copybara/git/GitLabMrWriteHook.java b/java/com/google/copybara/git/GitLabMrWriteHook.java new file mode 100644 index 000000000..d0b4c4026 --- /dev/null +++ b/java/com/google/copybara/git/GitLabMrWriteHook.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2022 Google Inc. + * + * 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.copybara.git; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.google.copybara.GeneralOptions; +import com.google.copybara.exception.RedundantChangeException; +import com.google.copybara.exception.RepoException; +import com.google.copybara.exception.ValidationException; +import com.google.copybara.git.GitDestination.MessageInfo; +import com.google.copybara.git.GitDestination.WriterImpl.DefaultWriteHook; +import com.google.copybara.git.gitlab.api.GitLabApi; +import com.google.copybara.git.gitlab.api.MergeRequest; +import com.google.copybara.git.gitlab.util.GitLabHost; +import com.google.copybara.revision.Change; +import com.google.copybara.util.console.Console; + +import javax.annotation.Nullable; +import java.util.List; + +/** + * A write hook for GitLab Mr. + */ +public class GitLabMrWriteHook extends DefaultWriteHook { + + private final String repoUrl; + private final GeneralOptions generalOptions; + private final GitLabOptions gitLabOptions; + private final boolean partialFetch; + private final Console console; + private final GitLabHost glHost; + @Nullable + private final String prBranchToUpdate; + private final boolean allowEmptyDiff; + + GitLabMrWriteHook( + GeneralOptions generalOptions, + String repoUrl, + GitLabOptions gitLabOptions, + @Nullable String prBranchToUpdate, + boolean partialFetch, + boolean allowEmptyDiff, + Console console, + GitLabHost glHost) { + this.generalOptions = Preconditions.checkNotNull(generalOptions); + this.repoUrl = Preconditions.checkNotNull(repoUrl); + this.gitLabOptions = Preconditions.checkNotNull(gitLabOptions); + this.prBranchToUpdate = prBranchToUpdate; + this.partialFetch = partialFetch; + this.allowEmptyDiff = allowEmptyDiff; + this.console = Preconditions.checkNotNull(console); + this.glHost = Preconditions.checkNotNull(glHost); + } + + @Override + public void beforePush( + GitRepository scratchClone, + MessageInfo messageInfo, + boolean skipPush, + List> originChanges) + throws ValidationException, RepoException { + if (skipPush || generalOptions.allowEmptyDiff(allowEmptyDiff)) { + return; + } + for (Change originalChange : originChanges) { + String configProjectName = glHost.getProjectNameFromUrl(repoUrl); + GitLabApi api = gitLabOptions.newGitLabApi(); + + ImmutableList mergeRequests = + api.getMergeRequests( + configProjectName, + prBranchToUpdate); + + // Just ignore empt-diff check when the size of prs is not equal to 1. + // If the list size is empty, no pr has been created before. + // If the list size is greater than 1, there might be something wrong. + // We don't want to throw EmptyChangeException for some of the mrs with the empty diff. + if (mergeRequests.size() != 1) { + return; + } + SameGitTree sameGitTree = + new SameGitTree(scratchClone, repoUrl, generalOptions, partialFetch); + MergeRequest mergeRequest = mergeRequests.get(0); + if (sameGitTree.hasSameTree(mergeRequest.getSha())) { + throw new RedundantChangeException( + String.format( + "Skipping push to the existing mr %s/-/merge_requests/%s as the change %s is empty.", + asHttpsUrl(), mergeRequest.getNumber(), originalChange.getRef()), + mergeRequest.getSha()); + } + } + } + + private String asHttpsUrl() throws ValidationException { + return gitLabOptions.getGitlabUrl() + "/" + glHost.getProjectNameFromUrl(repoUrl); + } + + protected GitLabMrWriteHook withUpdatedMrBranch(String prBranchToUpdate) { + return new GitLabMrWriteHook( + this.generalOptions, + this.repoUrl, + this.gitLabOptions, + prBranchToUpdate, + this.partialFetch, + this.allowEmptyDiff, + this.console, + this.glHost); + } +} diff --git a/java/com/google/copybara/git/GitLabOptions.java b/java/com/google/copybara/git/GitLabOptions.java new file mode 100644 index 000000000..d5d1e4b47 --- /dev/null +++ b/java/com/google/copybara/git/GitLabOptions.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2022 Google Inc. + * + * 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.copybara.git; + +import com.beust.jcommander.Parameter; +import com.beust.jcommander.Parameters; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import com.google.copybara.GeneralOptions; +import com.google.copybara.Option; +import com.google.copybara.checks.Checker; +import com.google.copybara.exception.RepoException; +import com.google.copybara.exception.ValidationException; +import com.google.copybara.git.gitlab.api.GitLabApi; +import com.google.copybara.git.gitlab.api.GitLabApiTransport; +import com.google.copybara.git.gitlab.api.GitLabApiTransportImpl; +import com.google.copybara.util.console.Console; + +import javax.annotation.Nullable; + +/** + * Options related to GitLab + */ +@Parameters(separators = "=") +public class GitLabOptions implements Option { + + protected final GeneralOptions generalOptions; + private final GitOptions gitOptions; + + public GitLabOptions(GeneralOptions generalOptions, GitOptions gitOptions) { + this.generalOptions = Preconditions.checkNotNull(generalOptions); + this.gitOptions = Preconditions.checkNotNull(gitOptions); + } + + @Parameter(names = "--gitlab-url", + description = "Overwrite default gitlab url", arity = 1, required = true) + String gitlabUrl = null; + + /** + * Returns a new {@link GitLabApi} instance for the given project. + * + *

The project for 'https://gitlab.com/foo/bar' is 'foo/bar'. + */ + public GitLabApi newGitLabApi() throws RepoException { + return newGitLabApi(generalOptions.console()); + } + + public String getGitlabUrl() { + return gitlabUrl; + } + + /** + * Returns a new {@link GitLabApi} instance for the given project enforcing the given + * {@link Checker}. + * + *

The project for 'https://gitlab.com/foo/bar' is 'foo/bar'. + */ + public GitLabApi newGitLabApi(Console console) + throws RepoException { + GitRepository repo = getCredentialsRepo(); + + String storePath = gitOptions.getCredentialHelperStorePath(); + if (storePath == null) { + storePath = "~/.git-credentials"; + } + GitLabApiTransport transport = newTransport(repo, storePath, console); + return new GitLabApi(transport, generalOptions.profiler()); + } + + @VisibleForTesting + protected GitRepository getCredentialsRepo() throws RepoException { + return gitOptions.cachedBareRepoForUrl("just_for_gitlab_api"); + } + + /** + * Validate if a {@link Checker} is valid to use with GitHub endpoints. + */ + public void validateEndpointChecker(@Nullable Checker checker) throws ValidationException { + // Accept any by default + } + + private GitLabApiTransport newTransport( + GitRepository repo, String storePath, Console console) { + return new GitLabApiTransportImpl(repo, newHttpTransport(), storePath, console, gitlabUrl); + } + + protected HttpTransport newHttpTransport() { + return new NetHttpTransport(); + } +} diff --git a/java/com/google/copybara/git/GitModule.java b/java/com/google/copybara/git/GitModule.java index e191271fb..c962afcb9 100644 --- a/java/com/google/copybara/git/GitModule.java +++ b/java/com/google/copybara/git/GitModule.java @@ -72,6 +72,7 @@ import com.google.copybara.git.github.api.AuthorAssociation; import com.google.copybara.git.github.api.GitHubEventType; import com.google.copybara.git.github.util.GitHubUtil; +import com.google.copybara.git.gitlab.util.GitLabHost; import com.google.copybara.transform.Replace; import com.google.copybara.transform.patch.PatchTransformation; import com.google.copybara.util.RepositoryUtil; @@ -1365,6 +1366,16 @@ private boolean convertDescribeVersion(Object describeVersion) { doc = "A checker that can check leaks or other checks in the commit created. ", named = true, positional = false), + @Param( + name = "push_options", + allowedTypes = { + @ParamType(type = Sequence.class), + @ParamType(type = NoneType.class), + }, + defaultValue = "None", + doc = "A sequence of git push options that can pass into push command. Defaults to none which represents no push options.", + named = true, + positional = false), }, useStarlarkThread = true) @UsesFlags(GitDestinationOptions.class) @@ -1378,6 +1389,7 @@ public GitDestination destination( Object integrates, Boolean primaryBranchMigration, Object checker, + Object pushOptions, StarlarkThread thread) throws EvalException { GitDestinationOptions destinationOptions = options.get(GitDestinationOptions.class); @@ -1391,6 +1403,9 @@ public GitDestination destination( "Skipping git checker for git.destination. Note that this could" + " cause leaks or other problems"); } + Iterable resolvedPushOptions = Starlark.isNullOrNone(pushOptions) + ? ImmutableList.of() + : Sequence.cast(pushOptions, String.class, "push_options"); return new GitDestination( fixHttp( checkNotEmpty(firstNotNull(destinationOptions.url, url), "url"), @@ -1410,7 +1425,8 @@ public GitDestination destination( Starlark.isNullOrNone(integrates) ? defaultGitIntegrate : Sequence.cast(integrates, GitIntegrateChanges.class, "integrates"), - maybeChecker); + maybeChecker, + resolvedPushOptions); } @SuppressWarnings("unused") @@ -1638,7 +1654,8 @@ public GitDestination gitHubDestination( Starlark.isNullOrNone(integrates) ? defaultGitIntegrate : Sequence.cast(integrates, GitIntegrateChanges.class, "integrates"), - checkerObj); + checkerObj, + ImmutableList.of()); } @SuppressWarnings("unused") @@ -1866,6 +1883,220 @@ public GitHubPrDestination githubPrDestination( checkerObj); } + @SuppressWarnings("unused") + @StarlarkMethod( + name = "gitlab_mr_destination", + doc = "Creates changes in a new merge request in the destination.", + parameters = { + @Param( + name = "url", + named = true, + doc = + "Url of the GitLab project. For example" + + " \"https://gitlab.com/some/project'\""), + @Param( + name = "destination_ref", + named = true, + doc = "Destination reference for the change.", + defaultValue = "'master'"), + @Param( + name = "mr_branch", + allowedTypes = { + @ParamType(type = String.class), + @ParamType(type = NoneType.class), + }, + defaultValue = "None", + named = true, + positional = false, + doc = + "Customize the merge request branch. Any variable present in the message in the " + + "form of ${CONTEXT_REFERENCE} will be replaced by the corresponding stable " + + "reference (head, MR number, Gerrit change number, etc.)."), + @Param( + name = "partial_fetch", + defaultValue = "False", + named = true, + positional = false, + doc = "This is an experimental feature that only works for certain origin globs."), + @Param( + name = "allow_empty_diff", + defaultValue = "True", + named = true, + positional = false, + doc = + "By default, copybara migrates changes without checking existing MRs. " + + "If set, copybara will skip pushing a change to an existing MR " + + "only if the git three of the pending migrating change is the same " + + "as the existing MR."), + @Param( + name = "title", + allowedTypes = { + @ParamType(type = String.class), + @ParamType(type = NoneType.class), + }, + defaultValue = "None", + named = true, + positional = false, + doc = + "When creating (or updating if `update_description` is set) a merge request, use" + + " this title. By default it uses the change first line. This field accepts" + + " a template with labels. For example: `\"Change ${CONTEXT_REFERENCE}\"`"), + @Param( + name = "body", + allowedTypes = { + @ParamType(type = String.class), + @ParamType(type = NoneType.class), + }, + defaultValue = "None", + named = true, + positional = false, + doc = + "When creating (or updating if `update_description` is set) a merge request, use" + + " this body. By default it uses the change summary. This field accepts" + + " a template with labels. For example: `\"Change ${CONTEXT_REFERENCE}\"`"), + @Param( + name = "integrates", + allowedTypes = { + @ParamType(type = Sequence.class, generic1 = GitIntegrateChanges.class), + @ParamType(type = NoneType.class), + }, + named = true, + defaultValue = "None", + doc = + "Integrate changes from a url present in the migrated change" + + " label. Defaults to a semi-fake merge if COPYBARA_INTEGRATE_REVIEW label is" + + " present in the message", + positional = false), + @Param( + name = "api_checker", + allowedTypes = { + @ParamType(type = Checker.class), + @ParamType(type = NoneType.class), + }, + defaultValue = "None", + doc = + "A checker for the GitLab API endpoint provided for after_migration hooks. " + + "This field is not required if the workflow hooks don't use the " + + "origin/destination endpoints.", + named = true, + positional = false), + @Param( + name = "update_description", + defaultValue = "False", + named = true, + positional = false, + doc = + "By default, Copybara only set the title and body of the MR when creating" + + " the MR. If this field is set to true, it will update those fields for" + + " every update."), + @Param( + name = "primary_branch_migration", + defaultValue = "False", + named = true, + positional = false, + doc = + "When enabled, copybara will ignore the 'desination_ref' param if it is 'master' or" + + " 'main' and instead try to establish the default git branch. If this fails," + + " it will fall back to the param's declared value.\n" + + "This is intended to help migrating to the new standard of using 'main'" + + " without breaking users relying on the legacy default."), + @Param( + name = "checker", + allowedTypes = { + @ParamType(type = Checker.class), + @ParamType(type = NoneType.class), + }, + defaultValue = "None", + doc = + "A checker that validates the commit files & message. If `api_checker` is not" + + " set, it will also be used for checking API calls. If only `api_checker`" + + "is used, that checker will only apply to API calls.", + named = true, + positional = false), + }, + useStarlarkThread = true) + @UsesFlags({GitDestinationOptions.class, GitLabDestinationOptions.class}) + @Example( + title = "Common usage", + before = "Create a branch by using copybara's computerIdentity algorithm:", + code = + "git.gitlab_mr_destination(\n" + + " url = \"https://gitlab.com/some/project\",\n" + + " destination_ref = \"master\",\n" + + " )") + @Example( + title = "Using mr_branch with label", + before = "Customize mr_branch with context reference:", + code = + "git.gitlab_mr_destination(\n" + + " url = \"https://gitlab.com/some/project\",\n" + + " destination_ref = \"master\",\n" + + " mr_branch = 'test_${CONTEXT_REFERENCE}',\n" + + " )") + @Example( + title = "Using mr_branch with constant string", + before = "Customize mr_branch with a constant string:", + code = + "git.gitlab_mr_destination(\n" + + " url = \"https://gitlab.com/some/project\",\n" + + " destination_ref = \"master\",\n" + + " mr_branch = 'test_my_branch',\n" + + " )") + public GitLabMrDestination gitLabMrDestination( + String url, + String destinationRef, + Object prBranch, + Boolean partialFetch, + Boolean allowEmptyDiff, + Object title, + Object body, + Object integrates, + Object apiChecker, + Boolean updateDescription, + Boolean primaryBranchMigrationMode, + Object checker, + StarlarkThread thread) + throws EvalException { + GeneralOptions generalOptions = options.get(GeneralOptions.class); + GitDestinationOptions destinationOptions = options.get(GitDestinationOptions.class); + GitLabOptions gitLabOptions = options.get(GitLabOptions.class); + String destinationPrBranch = convertFromNoneable(prBranch, null); + Checker apiCheckerObj = convertFromNoneable(apiChecker, null); + Checker checkerObj = convertFromNoneable(checker, null); + return new GitLabMrDestination( + fixHttp( + checkNotEmpty(firstNotNull(destinationOptions.url, url), "url"), + thread.getCallerLocation()), + destinationRef, + convertFromNoneable(prBranch, null), + partialFetch, + generalOptions, + options.get(GitLabOptions.class), + destinationOptions, + options.get(GitLabDestinationOptions.class), + options.get(GitOptions.class), + new GitLabMrWriteHook( + generalOptions, + url, + gitLabOptions, + destinationPrBranch, + partialFetch, + allowEmptyDiff, + getGeneralConsole(), + new GitLabHost()), + Starlark.isNullOrNone(integrates) + ? defaultGitIntegrate + : Sequence.cast(integrates, GitIntegrateChanges.class, "integrates"), + convertFromNoneable(title, null), + convertFromNoneable(body, null), + mainConfigFile, + apiCheckerObj != null ? apiCheckerObj : checkerObj, + updateDescription, + new GitLabHost(), + primaryBranchMigrationMode, + checkerObj); + } + @Nullable private static String firstNotNull(String... values) { for (String value : values) { if (!Strings.isNullOrEmpty(value)) { diff --git a/java/com/google/copybara/git/GitRepository.java b/java/com/google/copybara/git/GitRepository.java index 999062345..33eb23fa4 100644 --- a/java/com/google/copybara/git/GitRepository.java +++ b/java/com/google/copybara/git/GitRepository.java @@ -454,7 +454,7 @@ public LogCmd log(String referenceExpr) { @CheckReturnValue public PushCmd push() { - return new PushCmd(this, /*url=*/null, ImmutableList.of(), /*prune=*/false); + return new PushCmd(this, /*url=*/null, ImmutableList.of(), /*prune=*/false, ImmutableList.of()); } @CheckReturnValue @@ -696,6 +696,12 @@ protected String runPush(PushCmd pushCmd) throws RepoException, ValidationExcept cmd.add("--no-verify"); } + if(!pushCmd.pushOptions.isEmpty()) { + for(String option : pushCmd.pushOptions) { + cmd.add("--push-option=" + option); + } + } + if (pushCmd.url != null) { cmd.add(validateUrl(pushCmd.url)); for (Refspec refspec : pushCmd.refspecs) { @@ -1948,6 +1954,8 @@ public static class PushCmd { private final ImmutableList refspecs; private final boolean prune; + private final ImmutableList pushOptions; + @Nullable public String getUrl() { return url; @@ -1964,24 +1972,30 @@ public boolean isPrune() { @CheckReturnValue public PushCmd(GitRepository repo, @Nullable String url, ImmutableList refspecs, - boolean prune) { + boolean prune, ImmutableList pushOptions) { this.repo = checkNotNull(repo); this.url = url; this.refspecs = checkNotNull(refspecs); Preconditions.checkArgument(refspecs.isEmpty() || url != null, "refspec can only be" + " used when a url is passed"); this.prune = prune; + this.pushOptions = pushOptions; } @CheckReturnValue public PushCmd withRefspecs(String url, Iterable refspecs) { return new PushCmd(repo, checkNotNull(url), ImmutableList.copyOf(refspecs), - prune); + prune, pushOptions); } @CheckReturnValue public PushCmd prune(boolean prune) { - return new PushCmd(repo, url, this.refspecs, prune); + return new PushCmd(repo, url, this.refspecs, prune, pushOptions); + } + + @CheckReturnValue + public PushCmd pushOptions(Iterable pushOptions) { + return new PushCmd(repo, url, refspecs, prune, ImmutableList.copyOf(pushOptions)); } /** diff --git a/java/com/google/copybara/git/gitlab/BUILD b/java/com/google/copybara/git/gitlab/BUILD new file mode 100644 index 000000000..018cc5d6e --- /dev/null +++ b/java/com/google/copybara/git/gitlab/BUILD @@ -0,0 +1,58 @@ +# Copyright 2022 Google Inc. +# +# 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. + +licenses(["notice"]) + +package(default_visibility = ["//visibility:public"]) + +JAVACOPTS = [ + "-Xlint:unchecked", + "-source", + "1.8", +] + +java_library( + name = "util", + srcs = glob( + ["util/**/*.java"], + ), + javacopts = JAVACOPTS, + deps = [ + "//java/com/google/copybara/exception", + "//third_party:guava", + "//third_party:re2j", + ], +) + +java_library( + name = "api", + srcs = glob( + ["api/**/*.java"], + ), + javacopts = JAVACOPTS, + deps = [ + # TODO(dpoluyanov): cleanup deps before PR, for now just copied from github/api + "//java/com/google/copybara/checks", + "//java/com/google/copybara/exception", + "//java/com/google/copybara/git:core", + "//java/com/google/copybara/profiler", + "//java/com/google/copybara/util/console", + "//third_party:flogger", + "//third_party:google_http_client", + "//third_party:guava", + "//third_party:jsr305", + "//third_party:re2j", + "//third_party:starlark", + ], +) diff --git a/java/com/google/copybara/git/gitlab/api/GitLabApi.java b/java/com/google/copybara/git/gitlab/api/GitLabApi.java new file mode 100644 index 000000000..d466e64c5 --- /dev/null +++ b/java/com/google/copybara/git/gitlab/api/GitLabApi.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2022 Google Inc. + * + * 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.copybara.git.gitlab.api; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableListMultimap; +import com.google.common.reflect.TypeToken; +import com.google.copybara.exception.RepoException; +import com.google.copybara.exception.ValidationException; +import com.google.copybara.profiler.Profiler; + +import java.io.UnsupportedEncodingException; +import java.lang.reflect.Type; +import java.net.URLEncoder; +import java.util.List; + +public class GitLabApi { + private final GitLabApiTransport transport; + private final Profiler profiler; + + public static final int MAX_PER_PAGE = 100; + private static final int MAX_PAGES = 60000; + + public GitLabApi(GitLabApiTransport transport, Profiler profiler) { + this.transport = transport; + this.profiler = profiler; + } + + public ImmutableList getMergeRequests(String projectId, String sourceBranch) throws ValidationException, RepoException { + ImmutableListMultimap.Builder params = ImmutableListMultimap.builder(); + if (sourceBranch != null) { + params.put("source_branch", sourceBranch); + } + + return paginatedGet(String.format("projects/%s/merge_requests", escape(projectId)), + "gitlab_api_list_merge_requests", + new TypeToken>() { + }.getType(), "MergeRequest", + ImmutableListMultimap.of(), + params.build()); + } + + private static String escape(String query) { + try { + return URLEncoder.encode(query, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("Shouldn't fail", e); + } + } + + public MergeRequest updateMergeRequest(String projectId, long iid, String title, String description, String state_event) throws ValidationException, RepoException { + ImmutableListMultimap.Builder params = ImmutableListMultimap.builder(); + if (title != null) { + params.put("title", title); + } + if (description != null) { + params.put("description", description); + } + if (state_event != null) { + params.put("state_event", state_event); + } + + try (Profiler.ProfilerTask ignore = profiler.start("gitlab_api_update_merge_request")) { + return transport.put( + String.format("projects/%s/merge_requests/%d", escape(projectId), iid), MergeRequest.class, params.build()); + } + } + + public MergeRequest createMergeRequest(String projectId, String title, String description, String sourceBranch, String targetBranch) throws ValidationException, RepoException { + ImmutableListMultimap.Builder params = ImmutableListMultimap.builder(); + if (title != null) { + params.put("title", title); + } + if (description != null) { + params.put("description", description); + } + if (sourceBranch != null) { + params.put("source_branch", sourceBranch); + } + if (targetBranch != null) { + params.put("target_branch", targetBranch); + } + + try (Profiler.ProfilerTask ignore = profiler.start("gitlab_api_create_mr")) { + return transport.post( + String.format("projects/%s/merge_requests", escape(projectId)), MergeRequest.class, params.build()); + } + } + + private ImmutableList paginatedGet(String path, String profilerName, Type type, + String entity, ImmutableListMultimap headers, + ImmutableListMultimap params) + throws RepoException, ValidationException { + ImmutableList.Builder builder = ImmutableList.builder(); + int pages = 1; + while (path != null && pages <= MAX_PAGES) { + try (Profiler.ProfilerTask ignore = profiler.start(String.format("%s_page_%d", profilerName, pages))) { + ImmutableListMultimap requestParams = ImmutableListMultimap.builder() + .putAll(params) + .put("page", String.valueOf(pages)) + .put("per_page", String.valueOf(MAX_PER_PAGE)) + .build(); + + List page = transport.get(path, type, headers, requestParams); + if (page.isEmpty()) { + break; + } + builder.addAll(page); + pages++; + } + } + return builder.build(); + } +} diff --git a/java/com/google/copybara/git/gitlab/api/GitLabApiTransport.java b/java/com/google/copybara/git/gitlab/api/GitLabApiTransport.java new file mode 100644 index 000000000..ab1c4526e --- /dev/null +++ b/java/com/google/copybara/git/gitlab/api/GitLabApiTransport.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2022 Google Inc. + * + * 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.copybara.git.gitlab.api; + +import com.google.common.collect.ImmutableListMultimap; +import com.google.copybara.exception.RepoException; +import com.google.copybara.exception.ValidationException; + +import java.lang.reflect.Type; + +/** + * Http transport interface for talking to GitLab. + */ +public interface GitLabApiTransport { + + /** + * Do a HTTP GET call with headers. + * The return type will be different. + * Therefore, using generics type here + */ + T get(String path, Type responseType, ImmutableListMultimap headers, + ImmutableListMultimap params) + throws RepoException, ValidationException; + + /** + * Do a HTTP POST call + * The return type will be different. + * Therefore, using generics type here + */ + T post(String path, Type responseType, ImmutableListMultimap params) + throws RepoException, ValidationException; + + /** + * Do a HTTP DELETE call + */ + void delete(String path) throws RepoException, ValidationException; + + /** + * Do a HTTP PUT call + * The return type will be different. + * Therefore, using generics type here + */ + T put(String path, Type responseType, ImmutableListMultimap params) + throws RepoException, ValidationException; + +} diff --git a/java/com/google/copybara/git/gitlab/api/GitLabApiTransportImpl.java b/java/com/google/copybara/git/gitlab/api/GitLabApiTransportImpl.java new file mode 100644 index 000000000..14c202465 --- /dev/null +++ b/java/com/google/copybara/git/gitlab/api/GitLabApiTransportImpl.java @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2022 Google Inc. + * + * 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.copybara.git.gitlab.api; + +import com.google.api.client.http.EmptyContent; +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.JsonObjectParser; +import com.google.api.client.json.gson.GsonFactory; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableListMultimap; +import com.google.common.collect.Iterables; +import com.google.common.flogger.FluentLogger; +import com.google.copybara.exception.RepoException; +import com.google.copybara.exception.ValidationException; +import com.google.copybara.git.GitCredential.UserPassword; +import com.google.copybara.git.GitRepository; +import com.google.copybara.util.console.Console; + +import javax.annotation.Nullable; +import java.io.IOException; +import java.lang.reflect.Type; +import java.net.URI; +import java.time.Duration; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * An implementation of {@link GitLabApiTransport} that uses Google http client and gson for doing + * the requests. + */ +public class GitLabApiTransportImpl implements GitLabApiTransport { + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + private static final JsonFactory JSON_FACTORY = new GsonFactory(); + private static final String API_PREFIX = "/api/v4/"; + private final GitRepository repo; + private final HttpTransport httpTransport; + private final String storePath; + private final Console console; + private final String gitlabUrl; + + public GitLabApiTransportImpl(GitRepository repo, HttpTransport httpTransport, + String storePath, Console console, String gitlabUrl) { + this.repo = Preconditions.checkNotNull(repo); + this.httpTransport = Preconditions.checkNotNull(httpTransport); + this.storePath = storePath; + this.console = Preconditions.checkNotNull(console); + this.gitlabUrl = Preconditions.checkNotNull(gitlabUrl); + } + + @SuppressWarnings("unchecked") + @Override + public T get(String path, Type responseType, ImmutableListMultimap headers, + ImmutableListMultimap params) + throws RepoException, ValidationException { + HttpRequestFactory requestFactory = getHttpRequestFactory(getCredentialsIfPresent(), headers); + GenericUrl url = new GenericUrl(URI.create(gitlabUrl + API_PREFIX + path)); + url.putAll(params.asMap()); + + try { + HttpRequest httpRequest = requestFactory.buildGetRequest(url); + HttpResponse response = httpRequest.execute(); + Object responseObj = response.parseAs(responseType); + return (T) responseObj; + } catch (IOException e) { + throw new RepoException("Error running GitLab API operation " + path, e); + } + } + + @SuppressWarnings("unchecked") + @Nullable + private static String maybeGetLinkHeader(HttpResponse response) { + HttpHeaders headers = response.getHeaders(); + List link = (List) headers.get("Link"); + if (link == null) { + return null; + } + return Iterables.getOnlyElement(link); + } + + /** + * Credentials for API should be optional for any read operation (GET). + */ + @Nullable + private UserPassword getCredentialsIfPresent() throws RepoException { + try { + return getCredentials(); + } catch (ValidationException e) { + String msg = String + .format("GitHub credentials not found in %s. Assuming the repository is public.", + storePath); + logger.atInfo().log("%s", msg); + console.info(msg); + return null; + } + } + + @SuppressWarnings("unchecked") + @Override + public T post(String path, Type responseType, ImmutableListMultimap params) + throws RepoException, ValidationException { + HttpRequestFactory requestFactory = + getHttpRequestFactory(getCredentials(), ImmutableListMultimap.of()); + + GenericUrl url = new GenericUrl(URI.create(gitlabUrl + API_PREFIX + path)); + url.putAll(params.asMap()); + + try { + HttpRequest httpRequest = requestFactory.buildPostRequest(url, new EmptyContent()); + HttpResponse response = httpRequest.execute(); + Object responseObj = response.parseAs(responseType); + return (T) responseObj; + + } catch (IOException e) { + throw new RepoException("Error running GitLab API operation " + path, e); + } + } + + @SuppressWarnings("unchecked") + @Override + public T put(String path, Type responseType, ImmutableListMultimap params) + throws RepoException, ValidationException { + HttpRequestFactory requestFactory = + getHttpRequestFactory(getCredentials(), ImmutableListMultimap.of()); + + GenericUrl url = new GenericUrl(URI.create(gitlabUrl + API_PREFIX + path)); + url.putAll(params.asMap()); + + try { + HttpRequest httpRequest = requestFactory.buildPutRequest(url, new EmptyContent()); + HttpResponse response = httpRequest.execute(); + Object responseObj = response.parseAs(responseType); + return (T) responseObj; + + } catch (IOException e) { + throw new RepoException("Error running GitLab API operation " + path, e); + } + } + + + @Override + public void delete(String path) throws RepoException, ValidationException { + HttpRequestFactory requestFactory = + getHttpRequestFactory(getCredentials(), ImmutableListMultimap.of()); + + GenericUrl url = new GenericUrl(URI.create(gitlabUrl + API_PREFIX + path)); + try { + requestFactory.buildDeleteRequest(url).execute(); + } catch (IOException e) { + throw new RepoException("Error running GitLab API operation " + path, e); + } + } + + private HttpRequestFactory getHttpRequestFactory( + @Nullable UserPassword userPassword, ImmutableListMultimap headers) { + return httpTransport.createRequestFactory( + request -> { + request.setConnectTimeout((int) Duration.ofMinutes(1).toMillis()); + request.setReadTimeout((int) Duration.ofMinutes(1).toMillis()); + HttpHeaders httpHeaders = new HttpHeaders(); + if (userPassword != null) { + httpHeaders.set("PRIVATE-TOKEN", userPassword.getPassword_BeCareful()); + } + for (Map.Entry> header : headers.asMap().entrySet()) { + httpHeaders.put(header.getKey(), header.getValue()); + } + request.setHeaders(httpHeaders); + request.setParser(new JsonObjectParser(JSON_FACTORY)); + }); + } + + /** + * Gets the credentials from git credential helper. First we try + * to get it for the gitlab.com host, just in case the user has an specific token for that + * url + */ + private UserPassword getCredentials() throws RepoException, ValidationException { + try { + return repo.credentialFill(gitlabUrl + API_PREFIX); + } catch (ValidationException e) { + try { + return repo.credentialFill(gitlabUrl); + } catch (ValidationException e1) { + // Ugly, but helpful... + throw new ValidationException(String.format( + "Cannot get credentials for host https://gitlab.com/api from" + + " credentials helper. Make sure either your credential helper has the username" + + " and password/token or if you don't use one, that file '%s'" + + " contains one of the two lines: \nEither:\n" + + "https://USERNAME:TOKEN@gitlab.com\n" + + "Note that spaces or other special characters need to be escaped. For example" + + " ' ' should be %%20 and '@' should be %%40 (For example when using the email" + + " as username)", storePath), e1); + } + } + } +} diff --git a/java/com/google/copybara/git/gitlab/api/MergeRequest.java b/java/com/google/copybara/git/gitlab/api/MergeRequest.java new file mode 100644 index 000000000..5bfc88b6f --- /dev/null +++ b/java/com/google/copybara/git/gitlab/api/MergeRequest.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2022 Google Inc. + * + * 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.copybara.git.gitlab.api; + +import com.google.api.client.util.Key; + +public class MergeRequest { + @Key + private String title; + @Key + private String description; + @Key("target_branch") + String targetBranch; + @Key("source_branch") + private String sourceBranch; + @Key + private String state; + @Key + private long iid; + @Key + private String sha; + + public String getTargetBranch() { + return targetBranch; + } + + public String getSourceBranch() { + return sourceBranch; + } + + public boolean isOpen() { + return state.equals("opened"); + } + + public long getNumber() { + return iid; + } + + public String getHtmlUrl() { + return ""; + } + + public String getSha() { + return sha; + } + +} diff --git a/java/com/google/copybara/git/gitlab/util/GitLabHost.java b/java/com/google/copybara/git/gitlab/util/GitLabHost.java new file mode 100644 index 000000000..72b85bec3 --- /dev/null +++ b/java/com/google/copybara/git/gitlab/util/GitLabHost.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2022 Google Inc. + * + * 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.copybara.git.gitlab.util; + +import com.google.common.base.Strings; +import com.google.copybara.exception.ValidationException; +import com.google.re2j.Matcher; +import com.google.re2j.Pattern; + +import java.net.URI; + +import static com.google.copybara.exception.ValidationException.checkCondition; + +public class GitLabHost { + + public String getProjectNameFromUrl(String url) throws ValidationException { + checkCondition(!Strings.isNullOrEmpty(url), "Empty url"); + + String gitProtocolPrefix = "git@"; + int separator = url.indexOf(":"); + if (url.startsWith(gitProtocolPrefix) && separator > 0) { + return url.substring(gitProtocolPrefix.length(), separator + 1).replaceAll("([.]git|/)$", ""); + } + + URI uri; + try { + uri = URI.create(url); + } catch (IllegalArgumentException e) { + throw new ValidationException("Cannot find project name from url " + url); + } + if (uri.getScheme() == null) { + uri = URI.create("notimportant://" + url); + } + + String name = uri.getPath().replaceAll("^/", "").replaceAll("([.]git|/)$", ""); + Matcher firstTwo = Pattern.compile("^([^/]+/[^/]+).*$").matcher(name); + if (firstTwo.matches()) { + name = firstTwo.group(1); + } + + checkCondition(!Strings.isNullOrEmpty(name), "Cannot find project name from url %s", url); + return name; + } +} diff --git a/javatests/com/google/copybara/git/GitDestinationIntegrateTest.java b/javatests/com/google/copybara/git/GitDestinationIntegrateTest.java index c2373cd5e..8dc120e78 100644 --- a/javatests/com/google/copybara/git/GitDestinationIntegrateTest.java +++ b/javatests/com/google/copybara/git/GitDestinationIntegrateTest.java @@ -351,6 +351,25 @@ public void testIncludeFiles() throws ValidationException, IOException, RepoExce .isEqualTo(Lists.newArrayList(previous.getCommit().getSha1())); } + @Test + public void testPushOptionsTrigger() throws Exception { + RepoException e = assertThrows(RepoException.class, () -> migrateOriginChange( + destination( + "url = '" + url + "'", + String.format("fetch = '%s'", primaryBranch), + String.format("push = '%s'", primaryBranch), + "integrates = []", + "push_options = ['ci.skip']"), + "Test change\n\n" + + GitModule.DEFAULT_INTEGRATE_LABEL + "=http://should_not_be_used\n", "some content")); + assertThat(e) + .hasMessageThat() + .contains("--push-option=ci.skip"); + assertThat(e) + .hasMessageThat() + .contains("the receiving end does not support push options"); + } + @Test public void testIncludeFilesOutdatedBranch() throws Exception { Path repoPath = Files.createTempDirectory("test"); diff --git a/javatests/com/google/copybara/git/GitRepositoryTest.java b/javatests/com/google/copybara/git/GitRepositoryTest.java index a83186142..260e6aa5e 100644 --- a/javatests/com/google/copybara/git/GitRepositoryTest.java +++ b/javatests/com/google/copybara/git/GitRepositoryTest.java @@ -1179,7 +1179,7 @@ public void doPushWithHook(GitRepository origin) throws Exception { origin, "file://" + remote.getGitDir(), ImmutableList.of(repository.createRefSpec("+" + defaultBranch + ":" + defaultBranch)), - false).run(); + false, ImmutableList.of()).run(); }