diff --git a/org.eclipse.egit.pullrequest.test/src/org/eclipse/egit/pullrequest/internal/bitbucket/BitbucketConnectionTest.java b/org.eclipse.egit.pullrequest.test/src/org/eclipse/egit/pullrequest/internal/bitbucket/BitbucketConnectionTest.java index 824f734..1fa98b2 100644 --- a/org.eclipse.egit.pullrequest.test/src/org/eclipse/egit/pullrequest/internal/bitbucket/BitbucketConnectionTest.java +++ b/org.eclipse.egit.pullrequest.test/src/org/eclipse/egit/pullrequest/internal/bitbucket/BitbucketConnectionTest.java @@ -80,6 +80,18 @@ public void testCurrentUserComesFromUsernameHeader() throws IOException { not(hasItem(containsString("users/current")))); //$NON-NLS-1$ } + @Test + public void testContextPathIsDetectedAutomatically() throws IOException { + String contextProperties = "/bitbucket" + PROPERTIES_PATH; //$NON-NLS-1$ + server.on(contextProperties, new Response(200, "OK", //$NON-NLS-1$ + "{\"version\":\"9.4.2\"}") //$NON-NLS-1$ + .header("X-AUSERNAME", "john.doe")); //$NON-NLS-1$ //$NON-NLS-2$ + + assertThat(client().getCurrentUser(), equalTo("john.doe")); //$NON-NLS-1$ + assertThat(server.requestedPaths(), + hasItem(equalTo(contextProperties))); + } + @Test public void testCurrentUserIsUrlDecoded() throws IOException { serveApplicationProperties("john.doe%40example.com"); //$NON-NLS-1$ diff --git a/org.eclipse.egit.pullrequest.test/src/org/eclipse/egit/pullrequest/internal/github/GitHubClientTest.java b/org.eclipse.egit.pullrequest.test/src/org/eclipse/egit/pullrequest/internal/github/GitHubClientTest.java index 8f2bf67..71d86c2 100644 --- a/org.eclipse.egit.pullrequest.test/src/org/eclipse/egit/pullrequest/internal/github/GitHubClientTest.java +++ b/org.eclipse.egit.pullrequest.test/src/org/eclipse/egit/pullrequest/internal/github/GitHubClientTest.java @@ -12,8 +12,11 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.notNullValue; +import java.util.List; + import org.junit.Test; /** @@ -28,6 +31,26 @@ public void testClientConstruction() { assertThat(client, notNullValue()); } + @Test + public void testTokenOnlyClientConstruction() { + assertThat(new GitHubClient("test-token"), notNullValue()); //$NON-NLS-1$ + } + + @Test + public void testParsePullRequestPathsFromSearch() { + String json = "{\"total_count\":2,\"items\":[" //$NON-NLS-1$ + + "{\"pull_request\":{\"url\":\"https://api.github.com/repos/alice/one/pulls/7\"}}," //$NON-NLS-1$ + + "{\"pull_request\":{\"url\":\"https://api.github.com/repos/acme/two/pulls/12\"}}" //$NON-NLS-1$ + + "]}"; //$NON-NLS-1$ + + List paths = GitHubJsonParser + .parseSearchPullRequestPaths(json); + + assertThat(paths, hasSize(2)); + assertThat(paths.get(0), equalTo("/repos/alice/one/pulls/7")); //$NON-NLS-1$ + assertThat(paths.get(1), equalTo("/repos/acme/two/pulls/12")); //$NON-NLS-1$ + } + @Test public void testExtractStringFromGitHubJsonParser() { String json = "{\"state\":\"APPROVED\",\"id\":123}"; //$NON-NLS-1$ diff --git a/org.eclipse.egit.pullrequest/META-INF/MANIFEST.MF b/org.eclipse.egit.pullrequest/META-INF/MANIFEST.MF index 94a2882..3e4e790 100644 --- a/org.eclipse.egit.pullrequest/META-INF/MANIFEST.MF +++ b/org.eclipse.egit.pullrequest/META-INF/MANIFEST.MF @@ -39,6 +39,7 @@ Import-Package: org.eclipse.core.resources.mapping, org.eclipse.egit.ui.internal.synchronize;version="[6.0.0,8.0.0)", org.eclipse.egit.ui.internal.synchronize.model;version="[6.0.0,8.0.0)", org.eclipse.jgit.annotations;version="[6.0.0,8.0.0)", + org.eclipse.jgit.api;version="[6.0.0,8.0.0)", org.eclipse.jgit.lib;version="[6.0.0,8.0.0)", org.eclipse.jgit.revwalk;version="[6.0.0,8.0.0)", org.eclipse.jgit.transport;version="[6.0.0,8.0.0)", diff --git a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/PRText.java b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/PRText.java index 285bc2e..bde8629 100644 --- a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/PRText.java +++ b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/PRText.java @@ -271,6 +271,27 @@ public class PRText extends NLS { /** */ public static String PullRequestSynchronizeLauncher_NoProjectsMessage; + /** */ + public static String CloneProject_Button; + + /** */ + public static String CloneProject_Cancel; + + /** */ + public static String CloneProject_JobName; + + /** */ + public static String CloneProject_ErrorTitle; + + /** */ + public static String CloneProject_Error; + + /** */ + public static String CloneProject_MissingUrl; + + /** */ + public static String CloneProject_DestinationExists; + /** */ public static String PreferencePage_DiagnosticsGroup; diff --git a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/bitbucket/BitbucketClient.java b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/bitbucket/BitbucketClient.java index e496dc9..0f84932 100644 --- a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/bitbucket/BitbucketClient.java +++ b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/bitbucket/BitbucketClient.java @@ -60,7 +60,11 @@ public class BitbucketClient implements IPullRequestClient { private static final int MAX_REPORTED_BODY = 400; - private final String serverUrl; + private final String configuredServerUrl; + + private volatile String serverUrl; + + private volatile boolean contextPathResolved; private final String projectKey; @@ -85,7 +89,8 @@ public class BitbucketClient implements IPullRequestClient { public BitbucketClient(@NonNull String serverUrl, @NonNull String projectKey, @NonNull String repositorySlug, @NonNull String token) { - this.serverUrl = serverUrl.endsWith("/") ? serverUrl.substring(0, serverUrl.length() - 1) : serverUrl; //$NON-NLS-1$ + this.serverUrl = trimTrailingSlash(serverUrl); + this.configuredServerUrl = this.serverUrl; this.projectKey = projectKey; this.repositorySlug = repositorySlug; this.token = token; @@ -687,7 +692,9 @@ public List getPullRequestCommits(long pullRequestId) */ private HttpURLConnection openConnection(String urlString, String method, String accept) throws IOException { - URL url = new URL(urlString); + resolveContextPath(); + String resolvedUrl = replaceConfiguredServerUrl(urlString); + URL url = new URL(resolvedUrl); Proxy proxy = HttpProxySupport.select(url); HttpURLConnection connection = (HttpURLConnection) (proxy == null ? url.openConnection() @@ -697,11 +704,114 @@ private HttpURLConnection openConnection(String urlString, String method, connection.setRequestProperty("Authorization", "Bearer " + token); //$NON-NLS-1$ //$NON-NLS-2$ connection.setConnectTimeout(DEFAULT_TIMEOUT); connection.setReadTimeout(DEFAULT_TIMEOUT); - Activator.logDebug(method + ' ' + urlString + Activator.logDebug(method + ' ' + resolvedUrl + (proxy == null ? "" : " (via " + proxy + ')')); //$NON-NLS-1$ //$NON-NLS-2$ return connection; } + private synchronized void resolveContextPath() { + if (contextPathResolved) { + return; + } + contextPathResolved = true; + + Probe root = probeServerBase(configuredServerUrl); + if (root.status == HttpURLConnection.HTTP_OK + || root.status == HttpURLConnection.HTTP_UNAUTHORIZED + || root.status == HttpURLConnection.HTTP_FORBIDDEN) { + return; + } + + String redirectedBase = contextBaseFromRedirect(root.location); + if (redirectedBase != null + && isBitbucketApi(redirectedBase)) { + serverUrl = redirectedBase; + return; + } + + String conventionalBase = configuredServerUrl + "/bitbucket"; //$NON-NLS-1$ + if (isBitbucketApi(conventionalBase)) { + serverUrl = conventionalBase; + Activator.logInfo("Detected Bitbucket context path: " + serverUrl); //$NON-NLS-1$ + } + } + + private boolean isBitbucketApi(String baseUrl) { + Probe probe = probeServerBase(baseUrl); + return probe.status == HttpURLConnection.HTTP_OK + || probe.status == HttpURLConnection.HTTP_UNAUTHORIZED + || probe.status == HttpURLConnection.HTTP_FORBIDDEN; + } + + private Probe probeServerBase(String baseUrl) { + Probe result = new Probe(); + HttpURLConnection connection = null; + try { + URL url = new URL(baseUrl + API_BASE_PATH + + "/application-properties"); //$NON-NLS-1$ + Proxy proxy = HttpProxySupport.select(url); + connection = (HttpURLConnection) (proxy == null + ? url.openConnection() + : url.openConnection(proxy)); + connection.setInstanceFollowRedirects(false); + connection.setRequestMethod("GET"); //$NON-NLS-1$ + connection.setRequestProperty("Accept", "application/json"); //$NON-NLS-1$ //$NON-NLS-2$ + connection.setRequestProperty("Authorization", //$NON-NLS-1$ + "Bearer " + token); //$NON-NLS-1$ + connection.setConnectTimeout(PROBE_TIMEOUT); + connection.setReadTimeout(PROBE_TIMEOUT); + result.status = connection.getResponseCode(); + result.location = connection.getHeaderField("Location"); //$NON-NLS-1$ + } catch (IOException e) { + result.failure = e.getMessage(); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + return result; + } + + private String contextBaseFromRedirect(String location) { + if (location == null || location.isBlank()) { + return null; + } + try { + URI configured = URI.create(configuredServerUrl); + URI redirect = configured.resolve(location); + if (!configured.getHost().equalsIgnoreCase(redirect.getHost())) { + return null; + } + String path = redirect.getPath(); + if (path == null || path.length() < 2) { + return null; + } + int secondSlash = path.indexOf('/', 1); + String context = secondSlash < 0 ? path + : path.substring(0, secondSlash); + return trimTrailingSlash(configuredServerUrl + context); + } catch (IllegalArgumentException e) { + return null; + } + } + + private String replaceConfiguredServerUrl(String urlString) { + if (!serverUrl.equals(configuredServerUrl) + && urlString.startsWith(configuredServerUrl)) { + return serverUrl + + urlString.substring(configuredServerUrl.length()); + } + return urlString; + } + + private static String trimTrailingSlash(String url) { + String result = url.trim(); + while (result.endsWith("/")) { //$NON-NLS-1$ + result = result.substring(0, result.length() - 1); + } + return result; + } + /** * Reads the status code, translating transport level failures into a * message that names the likely cause. diff --git a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/client/IPullRequestClient.java b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/client/IPullRequestClient.java index ca0f353..5ea36dd 100644 --- a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/client/IPullRequestClient.java +++ b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/client/IPullRequestClient.java @@ -18,7 +18,19 @@ public interface IPullRequestClient { /** - * Retrieves pull requests for the configured repository + * Selects the repository associated with a pull request. Repository-scoped + * providers may ignore this; providers that aggregate several repositories + * use it to route subsequent operations. + * + * @param pullRequest + * the pull request that subsequent operations concern + */ + default void setActivePullRequest(PullRequest pullRequest) { + // Most provider clients are permanently scoped to one repository. + } + + /** + * Retrieves pull requests visible in the configured provider scope. * * @param state * the PR state filter (e.g., "OPEN", "MERGED", "DECLINED" for diff --git a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/client/PullRequestClientFactory.java b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/client/PullRequestClientFactory.java index 0a44b3d..727e4de 100644 --- a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/client/PullRequestClientFactory.java +++ b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/client/PullRequestClientFactory.java @@ -6,6 +6,7 @@ import org.eclipse.egit.pullrequest.internal.PRPreferences; import org.eclipse.egit.pullrequest.internal.bitbucket.BitbucketClient; import org.eclipse.egit.pullrequest.internal.github.GitHubClient; +import org.eclipse.egit.pullrequest.internal.model.PullRequest; /** * Factory for creating pull request client instances based on configured @@ -80,6 +81,21 @@ public static IPullRequestClient createClient() { return client; } + /** + * Creates a client and selects the repository for a pull request. + * + * @param pullRequest + * pull request that subsequent client operations concern + * @return the configured client, or null if not properly configured + */ + public static IPullRequestClient createClient(PullRequest pullRequest) { + IPullRequestClient client = createClient(); + if (client != null) { + client.setActivePullRequest(pullRequest); + } + return client; + } + /** * Describes which configuration values are present, without revealing the * access tokens. @@ -90,8 +106,7 @@ public static IPullRequestClient createClient() { */ private static String describe(ClientConfig config) { if (config.providerType == PullRequestProviderType.GITHUB) { - return "owner=" + quote(config.githubOwner) + ", repository=" //$NON-NLS-1$ //$NON-NLS-2$ - + quote(config.githubRepo) + ", token=" //$NON-NLS-1$ + return "token=" //$NON-NLS-1$ + (isBlank(config.githubAccessToken) ? "missing" : "set"); //$NON-NLS-1$ //$NON-NLS-2$ } return "server URL=" + quote(config.bitbucketServerUrl) //$NON-NLS-1$ @@ -130,12 +145,10 @@ public static IPullRequestClient createClient(ClientConfig config) { config.bitbucketAccessToken); case GITHUB: - if (isBlank(config.githubOwner) || isBlank(config.githubRepo) - || isBlank(config.githubAccessToken)) { + if (isBlank(config.githubAccessToken)) { return null; } - return new GitHubClient(config.githubOwner, config.githubRepo, - config.githubAccessToken); + return new GitHubClient(config.githubAccessToken); default: return null; diff --git a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/github/GitHubClient.java b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/github/GitHubClient.java index d73be3a..66d5b44 100644 --- a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/github/GitHubClient.java +++ b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/github/GitHubClient.java @@ -7,6 +7,7 @@ import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; +import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; @@ -35,9 +36,9 @@ public class GitHubClient implements IPullRequestClient { private static final int DEFAULT_TIMEOUT = 30000; // 30 seconds - private final String owner; + private String owner; - private final String repo; + private String repo; private final String token; @@ -62,6 +63,35 @@ public GitHubClient(@NonNull String owner, @NonNull String repo, .forProvider(PullRequestProviderType.GITHUB); } + /** + * Creates a token-scoped GitHub client that lists pull requests authored by + * the authenticated user across all repositories visible to the token. + * + * @param token + * the GitHub access token + */ + public GitHubClient(@NonNull String token) { + this.owner = null; + this.repo = null; + this.token = token; + this.capabilities = PullRequestProviderCapabilities + .forProvider(PullRequestProviderType.GITHUB); + } + + @Override + public void setActivePullRequest(PullRequest pullRequest) { + if (pullRequest == null || pullRequest.getToRef() == null + || pullRequest.getToRef().getRepository() == null) { + return; + } + PullRequest.Repository repository = pullRequest.getToRef() + .getRepository(); + if (repository.getProject() != null) { + owner = repository.getProject().getKey(); + } + repo = repository.getSlug(); + } + @Override public @NonNull PullRequestProviderType getProviderType() { return PullRequestProviderType.GITHUB; @@ -77,6 +107,9 @@ public GitHubClient(@NonNull String owner, @NonNull String repo, @Nullable String authorUsername, @Nullable String reviewerUsername, int limit, int start) throws IOException { + if (owner == null || repo == null) { + return getUserPullRequests(state, authorUsername, limit, start); + } StringBuilder urlBuilder = new StringBuilder(); urlBuilder.append("/repos/").append(owner).append("/").append(repo) //$NON-NLS-1$ //$NON-NLS-2$ .append("/pulls"); //$NON-NLS-1$ @@ -160,6 +193,39 @@ public GitHubClient(@NonNull String owner, @NonNull String repo, return pulls; } + private List getUserPullRequests(String state, + String authorUsername, int limit, int start) throws IOException { + String author = authorUsername == null || authorUsername.isBlank() + ? "@me" : authorUsername; //$NON-NLS-1$ + StringBuilder query = new StringBuilder("is:pr author:") //$NON-NLS-1$ + .append(author); + if ("MERGED".equalsIgnoreCase(state)) { //$NON-NLS-1$ + query.append(" is:merged"); //$NON-NLS-1$ + } else if ("DECLINED".equalsIgnoreCase(state)) { //$NON-NLS-1$ + query.append(" is:closed is:unmerged"); //$NON-NLS-1$ + } else if (state == null || !"ALL".equalsIgnoreCase(state)) { //$NON-NLS-1$ + query.append(" is:open"); //$NON-NLS-1$ + } + + int pageSize = Math.max(1, Math.min(limit, 100)); + int page = start / pageSize + 1; + String path = "/search/issues?q=" //$NON-NLS-1$ + + URLEncoder.encode(query.toString(), StandardCharsets.UTF_8) + + "&per_page=" + pageSize + "&page=" + page; //$NON-NLS-1$ //$NON-NLS-2$ + String searchResult = doGet(path); + List pullRequestPaths = GitHubJsonParser + .parseSearchPullRequestPaths(searchResult); + List result = new ArrayList<>(); + for (String pullRequestPath : pullRequestPaths) { + PullRequest pullRequest = GitHubJsonParser + .parseSinglePullRequest(doGet(pullRequestPath)); + if (pullRequest != null) { + result.add(pullRequest); + } + } + return result; + } + @Override public @NonNull PullRequest getPullRequest(long pullRequestId) throws IOException { @@ -732,13 +798,10 @@ public void deleteComment(long pullRequestId, long commentId, int version, @Override public boolean testConnection() { try { - // Try to get the repository info - String path = "/repos/" + owner + "/" + repo; //$NON-NLS-1$ //$NON-NLS-2$ - doGet(path); + doGet("/user"); //$NON-NLS-1$ return true; } catch (IOException e) { - Activator.logError("Cannot reach GitHub repository " + owner + '/' //$NON-NLS-1$ - + repo, e); + Activator.logError("Cannot authenticate with GitHub", e); //$NON-NLS-1$ return false; } } diff --git a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/github/GitHubJsonParser.java b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/github/GitHubJsonParser.java index ac0759b..bbcb031 100644 --- a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/github/GitHubJsonParser.java +++ b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/github/GitHubJsonParser.java @@ -9,6 +9,8 @@ import java.util.List; import java.util.Map; import java.util.TimeZone; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.eclipse.egit.pullrequest.Activator; import org.eclipse.egit.pullrequest.internal.model.ChangedFile; @@ -23,6 +25,30 @@ class GitHubJsonParser { private static final String ISO8601_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'"; //$NON-NLS-1$ + private static final Pattern SEARCH_PULL_URL = Pattern.compile( + "\"pull_request\"\\s*:\\s*\\{[^}]*\"url\"\\s*:\\s*" //$NON-NLS-1$ + + "\"https://api\\.github\\.com([^\"?]+)\"", //$NON-NLS-1$ + Pattern.DOTALL); + + /** + * Extracts REST pull request paths from a GitHub issue search response. + * + * @param json + * search response JSON + * @return API paths for the pull requests in result order + */ + static List parseSearchPullRequestPaths(String json) { + List result = new ArrayList<>(); + if (json == null || json.isBlank()) { + return result; + } + Matcher matcher = SEARCH_PULL_URL.matcher(json); + while (matcher.find()) { + result.add(matcher.group(1)); + } + return result; + } + /** * Parses a list of pull requests from GitHub API JSON * diff --git a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/prtext.properties b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/prtext.properties index 7b23770..cff43ad 100644 --- a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/prtext.properties +++ b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/prtext.properties @@ -86,6 +86,13 @@ PullRequestSynchronizeLauncher_RepoNotFoundTitle=Repository Not Found PullRequestSynchronizeLauncher_RepoNotFoundMessage=The repository for this pull request is not cloned locally.\n\nPlease clone the repository to your workspace and try again. PullRequestSynchronizeLauncher_WarningTitle=No Projects Found PullRequestSynchronizeLauncher_NoProjectsMessage=No workspace projects are mapped to this repository.\n\nPlease import the repository as an Eclipse project to view file changes. +CloneProject_Button=Clone Project +CloneProject_Cancel=Cancel +CloneProject_JobName=Cloning pull request project +CloneProject_ErrorTitle=Clone Project Error +CloneProject_Error=Failed to clone the project +CloneProject_MissingUrl=The pull request does not provide an HTTPS clone URL. +CloneProject_DestinationExists=The predefined workspace destination already exists. PreferencePage_DiagnosticsGroup=Diagnostics PreferencePage_VerboseLogging=&Log every provider request to the Eclipse log (verbose) diff --git a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/ClonePullRequestRepositoryJob.java b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/ClonePullRequestRepositoryJob.java new file mode 100644 index 0000000..6c16c73 --- /dev/null +++ b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/ClonePullRequestRepositoryJob.java @@ -0,0 +1,163 @@ +/******************************************************************************* + * Copyright (C) 2026, Eclipse EGit contributors + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ +package org.eclipse.egit.pullrequest.internal.ui; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.List; +import java.util.stream.Stream; + +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.IProjectDescription; +import org.eclipse.core.resources.IWorkspace; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Path; +import org.eclipse.core.runtime.Status; +import org.eclipse.core.runtime.jobs.Job; +import org.eclipse.egit.core.RepositoryUtil; +import org.eclipse.egit.pullrequest.Activator; +import org.eclipse.egit.pullrequest.internal.PRPreferences; +import org.eclipse.egit.pullrequest.internal.PRText; +import org.eclipse.egit.pullrequest.internal.model.PullRequest; +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; + +/** + * Clones a pull request's target repository and imports Eclipse projects found + * in it. + */ +public class ClonePullRequestRepositoryJob extends Job { + + private final PullRequest pullRequest; + + private final Shell shell; + + private final Runnable completion; + + /** + * Creates a repository clone job. + * + * @param pullRequest + * pull request whose target repository will be cloned + * @param shell + * parent shell for error dialogs + * @param completion + * action to run on the UI thread after cloning + */ + public ClonePullRequestRepositoryJob(PullRequest pullRequest, Shell shell, + Runnable completion) { + super(PRText.CloneProject_JobName); + this.pullRequest = pullRequest; + this.shell = shell; + this.completion = completion; + setUser(true); + } + + @Override + protected IStatus run(IProgressMonitor monitor) { + PullRequest.Repository repository = pullRequest.getToRef() + .getRepository(); + String cloneUrl = repository.getCloneUrl(); + if (cloneUrl == null || cloneUrl.isBlank()) { + return fail(PRText.CloneProject_MissingUrl, null); + } + + IPath workspacePath = ResourcesPlugin.getWorkspace().getRoot() + .getLocation(); + File destination = workspacePath.append(repository.getSlug()).toFile(); + if (destination.exists()) { + return fail(PRText.CloneProject_DestinationExists, null); + } + + monitor.beginTask(PRText.CloneProject_JobName, + IProgressMonitor.UNKNOWN); + try (Git git = Git.cloneRepository().setURI(cloneUrl) + .setDirectory(destination) + .setCredentialsProvider(credentialsProvider()).call()) { + RepositoryUtil.INSTANCE + .addConfiguredRepository(git.getRepository().getDirectory()); + importProjects(destination, monitor); + Display.getDefault().asyncExec(completion); + return Status.OK_STATUS; + } catch (Exception e) { + return fail(e.getMessage(), e); + } finally { + monitor.done(); + } + } + + private CredentialsProvider credentialsProvider() { + String provider = preference( + PRPreferences.PULLREQUEST_PROVIDER_TYPE); + String username; + String token; + if ("GITHUB".equals(provider)) { //$NON-NLS-1$ + username = "x-access-token"; //$NON-NLS-1$ + token = preference(PRPreferences.GITHUB_ACCESS_TOKEN); + } else { + username = preference(PRPreferences.BITBUCKET_USERNAME); + token = preference(PRPreferences.BITBUCKET_ACCESS_TOKEN); + } + if (username.isBlank() || token.isBlank()) { + return null; + } + return new UsernamePasswordCredentialsProvider(username, token); + } + + private static String preference(String key) { + return Activator.getDefault().getPreferenceStore().getString(key); + } + + private static void importProjects(File destination, + IProgressMonitor monitor) throws IOException, CoreException { + IWorkspace workspace = ResourcesPlugin.getWorkspace(); + List projectFiles; + try (Stream paths = Files + .walk(destination.toPath())) { + projectFiles = paths.filter(path -> ".project".equals( //$NON-NLS-1$ + path.getFileName().toString())).toList(); + } + + for (java.nio.file.Path projectFile : projectFiles) { + IProjectDescription description = workspace + .loadProjectDescription( + new Path(projectFile.toAbsolutePath().toString())); + IProject project = workspace.getRoot() + .getProject(description.getName()); + if (project.exists()) { + continue; + } + description.setLocation(new Path( + projectFile.getParent().toAbsolutePath().toString())); + project.create(description, monitor); + project.open(monitor); + } + } + + private IStatus fail(String detail, Throwable error) { + String message = PRText.CloneProject_Error + + (detail == null || detail.isBlank() + ? "" : ": " + detail); //$NON-NLS-1$ //$NON-NLS-2$ + Activator.logError(message, error); + Display.getDefault().asyncExec(() -> MessageDialog.openError(shell, + PRText.CloneProject_ErrorTitle, message)); + return new Status(IStatus.ERROR, Activator.PLUGIN_ID, message, error); + } +} diff --git a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/CommentActionExecutor.java b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/CommentActionExecutor.java index a2f6a74..e8d048b 100644 --- a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/CommentActionExecutor.java +++ b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/CommentActionExecutor.java @@ -104,7 +104,7 @@ void handleReply(PullRequestComment comment, String fileType) { protected IStatus run(IProgressMonitor monitor) { try { IPullRequestClient client = PullRequestClientFactory - .createClient(); + .createClient(pr); if (client == null) { return new Status(IStatus.ERROR, Activator.PLUGIN_ID, @@ -153,7 +153,7 @@ void handleResolve(PullRequestComment comment) { protected IStatus run(IProgressMonitor monitor) { try { IPullRequestClient client = PullRequestClientFactory - .createClient(); + .createClient(pr); if (client == null) { return new Status(IStatus.ERROR, Activator.PLUGIN_ID, @@ -212,7 +212,7 @@ void handleDelete(PullRequestComment comment) { protected IStatus run(IProgressMonitor monitor) { try { IPullRequestClient client = PullRequestClientFactory - .createClient(); + .createClient(pr); if (client == null) { return new Status(IStatus.ERROR, Activator.PLUGIN_ID, @@ -273,7 +273,7 @@ void handleEdit(PullRequestComment comment) { protected IStatus run(IProgressMonitor monitor) { try { IPullRequestClient client = PullRequestClientFactory - .createClient(); + .createClient(pr); if (client == null) { return new Status(IStatus.ERROR, Activator.PLUGIN_ID, @@ -344,7 +344,7 @@ void handleNewComment(int line, String fileType, String filePath) { protected IStatus run(IProgressMonitor monitor) { try { IPullRequestClient client = PullRequestClientFactory - .createClient(); + .createClient(pr); if (client == null) { return new Status(IStatus.ERROR, Activator.PLUGIN_ID, diff --git a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/ManageReviewersAction.java b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/ManageReviewersAction.java index 408eaae..6d2e836 100644 --- a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/ManageReviewersAction.java +++ b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/ManageReviewersAction.java @@ -75,6 +75,7 @@ public void run() { "Pull request provider not configured"); //$NON-NLS-1$ return; } + client.setActivePullRequest(pullRequest); ReviewerManagementDialog dialog = new ReviewerManagementDialog(shell, pullRequest, client); diff --git a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/PullRequestCommitsView.java b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/PullRequestCommitsView.java index cfae2e6..59725e7 100644 --- a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/PullRequestCommitsView.java +++ b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/PullRequestCommitsView.java @@ -252,7 +252,7 @@ protected IStatus run(IProgressMonitor monitor) { try { IPullRequestClient client = PullRequestClientFactory - .createClient(); + .createClient(pr); if (client == null) { return new Status(IStatus.ERROR, Activator.PLUGIN_ID, PRText.CommitsView_ErrorProviderNotConfigured); diff --git a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/PullRequestPreferencePage.java b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/PullRequestPreferencePage.java index a4e7416..16a8a39 100644 --- a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/PullRequestPreferencePage.java +++ b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/PullRequestPreferencePage.java @@ -53,10 +53,6 @@ public class PullRequestPreferencePage extends PreferencePage private Text bitbucketTokenText; - private Text githubOwnerText; - - private Text githubRepoText; - private Text githubTokenText; private Button showInlineCommentsCheckbox; @@ -220,24 +216,6 @@ private Composite createGitHubConfiguration(Composite parent) { group.setLayout(new GridLayout(2, false)); GridDataFactory.fillDefaults().grab(true, false).applyTo(group); - // Owner - Label ownerLabel = new Label(group, SWT.NONE); - ownerLabel.setText("Repository &Owner:"); //$NON-NLS-1$ - - githubOwnerText = new Text(group, SWT.BORDER); - githubOwnerText.setToolTipText( - "GitHub user or organization (e.g., 'octocat' or 'eclipse')"); //$NON-NLS-1$ - GridDataFactory.fillDefaults().grab(true, false) - .applyTo(githubOwnerText); - - // Repository - Label repoLabel = new Label(group, SWT.NONE); - repoLabel.setText("Repository &Name:"); //$NON-NLS-1$ - - githubRepoText = new Text(group, SWT.BORDER); - githubRepoText.setToolTipText("GitHub repository name (e.g., 'egit')"); //$NON-NLS-1$ - GridDataFactory.fillDefaults().grab(true, false).applyTo(githubRepoText); - // Access token Label tokenLabel = new Label(group, SWT.NONE); tokenLabel.setText("Personal Access &Token:"); //$NON-NLS-1$ @@ -362,8 +340,6 @@ private void loadValues() { .setText(store.getString(PRPreferences.BITBUCKET_ACCESS_TOKEN)); // Load GitHub values - githubOwnerText.setText(store.getString(PRPreferences.GITHUB_OWNER)); - githubRepoText.setText(store.getString(PRPreferences.GITHUB_REPO)); githubTokenText .setText(store.getString(PRPreferences.GITHUB_ACCESS_TOKEN)); @@ -388,8 +364,6 @@ protected void performDefaults() { bitbucketUsernameText.setText(""); //$NON-NLS-1$ bitbucketTokenText.setText(""); //$NON-NLS-1$ - githubOwnerText.setText(""); //$NON-NLS-1$ - githubRepoText.setText(""); //$NON-NLS-1$ githubTokenText.setText(""); //$NON-NLS-1$ showInlineCommentsCheckbox.setSelection(true); @@ -424,10 +398,6 @@ public boolean performOk() { bitbucketTokenText.getText().trim()); // Save GitHub values - store.setValue(PRPreferences.GITHUB_OWNER, - githubOwnerText.getText().trim()); - store.setValue(PRPreferences.GITHUB_REPO, - githubRepoText.getText().trim()); store.setValue(PRPreferences.GITHUB_ACCESS_TOKEN, githubTokenText.getText().trim()); @@ -458,8 +428,6 @@ private void testBitbucketConnection() { private void testGitHubConnection() { PullRequestClientFactory.ClientConfig config = new PullRequestClientFactory.ClientConfig(); config.providerType = PullRequestProviderType.GITHUB; - config.githubOwner = githubOwnerText.getText().trim(); - config.githubRepo = githubRepoText.getText().trim(); config.githubAccessToken = githubTokenText.getText().trim(); runConnectionTest(config); diff --git a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/PullRequestSynchronizeLauncher.java b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/PullRequestSynchronizeLauncher.java index 7e1e01d..fcd3619 100644 --- a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/PullRequestSynchronizeLauncher.java +++ b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/PullRequestSynchronizeLauncher.java @@ -75,6 +75,7 @@ public static void launchForPullRequest(PullRequest pr) { if (client != null) { try { + client.setActivePullRequest(pr); List comments = client .getPullRequestComments(pr.getId()); context.setActivePullRequest(pr, client); @@ -91,7 +92,8 @@ public static void launchForPullRequest(PullRequest pr) { Repository repo = RepositoryResolver.resolve(pr); if (repo == null) { - showRepositoryNotFoundDialog(); + showRepositoryNotFoundDialog(pr, + () -> launchForPullRequest(pr)); return; } @@ -132,6 +134,7 @@ public static void launchForCommit(PullRequest pr, if (client != null) { try { + client.setActivePullRequest(pr); List comments = client .getPullRequestComments(pr.getId()); context.setActivePullRequest(pr, client); @@ -148,7 +151,8 @@ public static void launchForCommit(PullRequest pr, Repository repo = RepositoryResolver.resolve(pr); if (repo == null) { - showRepositoryNotFoundDialog(); + showRepositoryNotFoundDialog(pr, + () -> launchForCommit(pr, commit)); return; } @@ -195,6 +199,7 @@ public static void launchForCommitRange(PullRequest pr, if (client != null) { try { + client.setActivePullRequest(pr); List comments = client .getPullRequestComments(pr.getId()); context.setActivePullRequest(pr, client); @@ -211,7 +216,8 @@ public static void launchForCommitRange(PullRequest pr, Repository repo = RepositoryResolver.resolve(pr); if (repo == null) { - showRepositoryNotFoundDialog(); + showRepositoryNotFoundDialog(pr, + () -> launchForCommitRange(pr, baseCommit, headCommit)); return; } @@ -514,13 +520,23 @@ protected void initializeConfiguration( * Shows an error dialog explaining that the repository is not cloned * locally. */ - private static void showRepositoryNotFoundDialog() { + private static void showRepositoryNotFoundDialog(PullRequest pullRequest, + Runnable completion) { Display.getDefault().asyncExec(() -> { - MessageDialog.openError( - PlatformUI.getWorkbench().getActiveWorkbenchWindow() - .getShell(), + org.eclipse.swt.widgets.Shell shell = PlatformUI.getWorkbench() + .getActiveWorkbenchWindow().getShell(); + MessageDialog dialog = new MessageDialog(shell, PRText.PullRequestSynchronizeLauncher_RepoNotFoundTitle, - PRText.PullRequestSynchronizeLauncher_RepoNotFoundMessage); + null, + PRText.PullRequestSynchronizeLauncher_RepoNotFoundMessage, + MessageDialog.INFORMATION, + new String[] { PRText.CloneProject_Button, + PRText.CloneProject_Cancel }, + 0); + if (dialog.open() == 0) { + new ClonePullRequestRepositoryJob(pullRequest, shell, + completion).schedule(); + } }); } } diff --git a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/overview/AddMyselfAsReviewerAction.java b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/overview/AddMyselfAsReviewerAction.java index ba2cb18..b7ab0b5 100644 --- a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/overview/AddMyselfAsReviewerAction.java +++ b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/overview/AddMyselfAsReviewerAction.java @@ -83,6 +83,7 @@ public void run() { "Pull request provider not configured"); //$NON-NLS-1$ return; } + client.setActivePullRequest(pullRequest); Job job = new Job(PRText.AddMyselfAsReviewer_JobName) { @Override diff --git a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/overview/PullRequestOverviewView.java b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/overview/PullRequestOverviewView.java index f77b675..5618981 100644 --- a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/overview/PullRequestOverviewView.java +++ b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/overview/PullRequestOverviewView.java @@ -686,7 +686,11 @@ protected IStatus run(IProgressMonitor monitor) { } private IPullRequestClient createClient() { - return PullRequestClientFactory.createClient(); + IPullRequestClient result = PullRequestClientFactory.createClient(); + if (result != null && currentPullRequest != null) { + result.setActivePullRequest(currentPullRequest); + } + return result; } private void checkoutSourceBranch() { diff --git a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/util/RepositoryResolver.java b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/util/RepositoryResolver.java index e05cee7..3ed263a 100644 --- a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/util/RepositoryResolver.java +++ b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/util/RepositoryResolver.java @@ -43,10 +43,9 @@ public static Repository resolve(PullRequest pr) { + repoSlug.toLowerCase(); } else if ("GITHUB".equals(providerType)) { //$NON-NLS-1$ serverUrl = "github.com"; //$NON-NLS-1$ - String owner = Activator.getDefault().getPreferenceStore() - .getString(PRPreferences.GITHUB_OWNER); - String repo = Activator.getDefault().getPreferenceStore() - .getString(PRPreferences.GITHUB_REPO); + PullRequest.Repository target = pr.getToRef().getRepository(); + String owner = target.getProject().getKey(); + String repo = target.getSlug(); // Build expected path fragment: /{owner}/{repo} pathFragment = "/" + owner.toLowerCase() + "/" //$NON-NLS-1$ //$NON-NLS-2$ + repo.toLowerCase();