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 extends Change>> 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();
}