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 71d86c2..1c715b5 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 @@ -11,10 +11,14 @@ package org.eclipse.egit.pullrequest.internal.github; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; +import java.util.Arrays; import java.util.List; import org.junit.Test; @@ -36,6 +40,49 @@ public void testTokenOnlyClientConstruction() { assertThat(new GitHubClient("test-token"), notNullValue()); //$NON-NLS-1$ } + @Test + public void testAccessibleSearchCoversOwnedOrgsAndForeignRepos() { + List queries = GitHubSearchQueries + .accessiblePullRequestQueries("alice", //$NON-NLS-1$ + Arrays.asList("acme", "friends"), //$NON-NLS-1$ //$NON-NLS-2$ + Arrays.asList("alice/notes", "acme/app", //$NON-NLS-1$ //$NON-NLS-2$ + "bob/tool"), //$NON-NLS-1$ + null, null); + + assertThat(queries, contains("is:pr user:alice is:open", //$NON-NLS-1$ + "is:pr org:acme is:open", //$NON-NLS-1$ + "is:pr org:friends is:open", //$NON-NLS-1$ + "is:pr repo:bob/tool is:open")); //$NON-NLS-1$ + } + + @Test + public void testAccessibleSearchAppliesAuthorFilterOnlyWhenSet() { + List allAuthors = GitHubSearchQueries + .accessiblePullRequestQueries("alice", null, null, //$NON-NLS-1$ + "OPEN", null); //$NON-NLS-1$ + List oneAuthor = GitHubSearchQueries + .accessiblePullRequestQueries("alice", null, null, //$NON-NLS-1$ + "OPEN", "carol"); //$NON-NLS-1$ //$NON-NLS-2$ + + assertThat(allAuthors, contains("is:pr user:alice is:open")); //$NON-NLS-1$ + assertThat(allAuthors.get(0), not(containsString("author:"))); //$NON-NLS-1$ + assertThat(oneAuthor, + contains("is:pr user:alice author:carol is:open")); //$NON-NLS-1$ + } + + @Test + public void testAccessibleSearchMapsMergedAndAllStates() { + assertThat(GitHubSearchQueries.pullRequestQuery("user:alice", //$NON-NLS-1$ + "MERGED", null), //$NON-NLS-1$ + equalTo("is:pr user:alice is:merged")); //$NON-NLS-1$ + assertThat(GitHubSearchQueries.pullRequestQuery("org:acme", //$NON-NLS-1$ + "ALL", null), //$NON-NLS-1$ + equalTo("is:pr org:acme")); //$NON-NLS-1$ + assertThat(GitHubSearchQueries.pullRequestQuery("repo:bob/tool", //$NON-NLS-1$ + "DECLINED", null), //$NON-NLS-1$ + equalTo("is:pr repo:bob/tool is:closed is:unmerged")); //$NON-NLS-1$ + } + @Test public void testParsePullRequestPathsFromSearch() { String json = "{\"total_count\":2,\"items\":[" //$NON-NLS-1$ diff --git a/org.eclipse.egit.pullrequest.test/src/org/eclipse/egit/pullrequest/internal/github/GitHubJsonParserTest.java b/org.eclipse.egit.pullrequest.test/src/org/eclipse/egit/pullrequest/internal/github/GitHubJsonParserTest.java index 340f98c..f428e76 100644 --- a/org.eclipse.egit.pullrequest.test/src/org/eclipse/egit/pullrequest/internal/github/GitHubJsonParserTest.java +++ b/org.eclipse.egit.pullrequest.test/src/org/eclipse/egit/pullrequest/internal/github/GitHubJsonParserTest.java @@ -29,6 +29,30 @@ */ public class GitHubJsonParserTest { + @Test + public void testParseOrganizationLogins() { + String json = "[{\"login\":\"acme\",\"id\":1}," //$NON-NLS-1$ + + "{\"login\":\"friends\",\"id\":2}]"; //$NON-NLS-1$ + + assertThat(GitHubJsonParser.parseLogins(json), hasSize(2)); + assertThat(GitHubJsonParser.parseLogins(json).get(0), + equalTo("acme")); //$NON-NLS-1$ + assertThat(GitHubJsonParser.parseLogins(json).get(1), + equalTo("friends")); //$NON-NLS-1$ + } + + @Test + public void testParseRepositoryFullNamesIgnoresNestedOwner() { + String json = "[{\"full_name\":\"bob/tool\",\"owner\":{\"login\":\"bob\"}}," //$NON-NLS-1$ + + "{\"full_name\":\"acme/app\",\"owner\":{\"login\":\"acme\"}}]"; //$NON-NLS-1$ + + List names = GitHubJsonParser.parseRepositoryFullNames(json); + + assertThat(names, hasSize(2)); + assertThat(names.get(0), equalTo("bob/tool")); //$NON-NLS-1$ + assertThat(names.get(1), equalTo("acme/app")); //$NON-NLS-1$ + } + @Test public void testParseInlineComment() { String json = "{\"id\":123,\"body\":\"Fix this issue\",\"created_at\":\"2026-01-15T10:00:00Z\"," //$NON-NLS-1$ 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 16edf91..8d87db2 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 @@ -277,7 +277,7 @@ default ConnectionDiagnostics diagnoseConnection() { int count = getPullRequests(null, null, null, 1, 0).size(); diagnostics.add("Read pull requests", //$NON-NLS-1$ ConnectionDiagnostics.Outcome.OK, - "Repository returned " + count + " pull request(s)"); //$NON-NLS-1$ //$NON-NLS-2$ + "Returned " + count + " pull request(s)"); //$NON-NLS-1$ //$NON-NLS-2$ } catch (IOException | RuntimeException e) { diagnostics.add("Read pull requests", //$NON-NLS-1$ ConnectionDiagnostics.Outcome.FAILED, describe(e)); 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 bc76017..3280fea 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 @@ -10,6 +10,8 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -64,8 +66,8 @@ public GitHubClient(@NonNull String owner, @NonNull String repo, } /** - * Creates a token-scoped GitHub client that lists pull requests authored by - * the authenticated user across all repositories visible to the token. + * Creates a token-scoped GitHub client that lists pull requests from every + * repository the authenticated user owns or belongs to. * * @param token * the GitHub access token @@ -195,26 +197,38 @@ public void setActivePullRequest(PullRequest pullRequest) { 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$ + List queries = GitHubSearchQueries + .accessiblePullRequestQueries(getCurrentUser(), + listOrganizationLogins(), + listCollaboratorRepositories(), state, + authorUsername); + + int needed = Math.max(1, start + Math.max(1, limit)); + int pageSize = Math.min(100, needed); + LinkedHashSet pullRequestPaths = new LinkedHashSet<>(); + for (String query : queries) { + int collected = 0; + int page = 1; + while (collected < needed) { + String path = "/search/issues?q=" //$NON-NLS-1$ + + URLEncoder.encode(query, StandardCharsets.UTF_8) + + "&per_page=" + pageSize //$NON-NLS-1$ + + "&page=" + page //$NON-NLS-1$ + + "&sort=updated&order=desc"; //$NON-NLS-1$ + List pagePaths = GitHubJsonParser + .parseSearchPullRequestPaths(doGet(path)); + if (pagePaths.isEmpty()) { + break; + } + pullRequestPaths.addAll(pagePaths); + collected += pagePaths.size(); + if (pagePaths.size() < pageSize) { + break; + } + page++; + } } - 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 @@ -223,7 +237,28 @@ private List getUserPullRequests(String state, result.add(pullRequest); } } - return result; + result.sort(Comparator.comparing(PullRequest::getUpdatedDate, + Comparator.nullsLast(Comparator.reverseOrder()))); + int from = Math.min(Math.max(0, start), result.size()); + int to = Math.min(from + Math.max(0, limit), result.size()); + return new ArrayList<>(result.subList(from, to)); + } + + private List listOrganizationLogins() throws IOException { + List logins = new ArrayList<>(); + for (String page : doGetAllPages("/user/orgs?per_page=100")) { //$NON-NLS-1$ + logins.addAll(GitHubJsonParser.parseLogins(page)); + } + return logins; + } + + private List listCollaboratorRepositories() throws IOException { + List fullNames = new ArrayList<>(); + for (String page : doGetAllPages( + "/user/repos?affiliation=collaborator&per_page=100")) { //$NON-NLS-1$ + fullNames.addAll(GitHubJsonParser.parseRepositoryFullNames(page)); + } + return fullNames; } @Override 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 5953650..fbd8305 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 @@ -48,6 +48,88 @@ static List parseSearchPullRequestPaths(String json) { return result; } + /** + * Parses {@code login} values from a GitHub user or organization array. + * + * @param json + * JSON array from {@code /user/orgs} + * @return organization or user logins + */ + static List parseLogins(String json) { + List result = new ArrayList<>(); + for (String object : splitTopLevelObjects(json)) { + String login = extractString(object, "login"); //$NON-NLS-1$ + if (login != null && !login.isBlank()) { + result.add(login); + } + } + return result; + } + + /** + * Parses {@code owner/name} values from a GitHub repository array. + * + * @param json + * JSON array from {@code /user/repos} + * @return repository full names + */ + static List parseRepositoryFullNames(String json) { + List result = new ArrayList<>(); + for (String object : splitTopLevelObjects(json)) { + String fullName = extractString(object, "full_name"); //$NON-NLS-1$ + if (fullName != null && !fullName.isBlank()) { + result.add(fullName); + } + } + return result; + } + + private static List splitTopLevelObjects(String json) { + List result = new ArrayList<>(); + if (json == null || json.isBlank()) { + return result; + } + String trimmed = json.trim(); + if (!trimmed.startsWith("[")) { //$NON-NLS-1$ + return result; + } + int depth = 0; + int start = -1; + boolean inString = false; + boolean escaped = false; + for (int i = 0; i < trimmed.length(); i++) { + char c = trimmed.charAt(i); + if (escaped) { + escaped = false; + continue; + } + if (c == '\\') { + escaped = true; + continue; + } + if (c == '"') { + inString = !inString; + continue; + } + if (inString) { + continue; + } + if (c == '{') { + if (depth == 0) { + start = i; + } + depth++; + } else if (c == '}') { + depth--; + if (depth == 0 && start >= 0) { + result.add(trimmed.substring(start, i + 1)); + start = -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/github/GitHubSearchQueries.java b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/github/GitHubSearchQueries.java new file mode 100644 index 0000000..8708de8 --- /dev/null +++ b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/github/GitHubSearchQueries.java @@ -0,0 +1,112 @@ +/******************************************************************************* + * Copyright (C) 2026, Philipp0205 and others + * + * 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.github; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Builds GitHub issue-search queries that cover pull requests in repositories + * the authenticated user owns or belongs to. + */ +final class GitHubSearchQueries { + + private GitHubSearchQueries() { + // No instantiation + } + + /** + * Builds one search query per accessible owner scope. + *

+ * Personal repositories use {@code user:login}, organization membership + * uses {@code org:name}, and remaining collaborator repositories use + * {@code repo:owner/name}. Author is only applied when the list filter + * requests it. + * + * @param login + * authenticated GitHub login + * @param organizationLogins + * organizations the user belongs to + * @param collaboratorFullNames + * {@code owner/name} repositories the user collaborates on + * @param state + * pull request state filter, or {@code null} for open + * @param authorUsername + * author filter, or {@code null} for every author + * @return search query strings for {@code GET /search/issues} + */ + static List accessiblePullRequestQueries(String login, + List organizationLogins, + List collaboratorFullNames, String state, + String authorUsername) { + List queries = new ArrayList<>(); + if (login != null && !login.isBlank()) { + queries.add(pullRequestQuery("user:" + login, state, //$NON-NLS-1$ + authorUsername)); + } + + Set organizations = new HashSet<>(); + if (organizationLogins != null) { + for (String organization : organizationLogins) { + if (organization == null || organization.isBlank()) { + continue; + } + organizations.add(organization); + queries.add(pullRequestQuery("org:" + organization, state, //$NON-NLS-1$ + authorUsername)); + } + } + + if (collaboratorFullNames != null) { + for (String fullName : collaboratorFullNames) { + if (isCoveredByUserOrOrg(fullName, login, organizations)) { + continue; + } + queries.add(pullRequestQuery("repo:" + fullName, state, //$NON-NLS-1$ + authorUsername)); + } + } + return queries; + } + + static String pullRequestQuery(String ownerQualifier, String state, + String authorUsername) { + StringBuilder query = new StringBuilder("is:pr "); //$NON-NLS-1$ + query.append(ownerQualifier); + if (authorUsername != null && !authorUsername.isBlank()) { + query.append(" author:").append(authorUsername); //$NON-NLS-1$ + } + 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$ + } + return query.toString(); + } + + static boolean isCoveredByUserOrOrg(String fullName, String login, + Set organizations) { + if (fullName == null || fullName.isBlank()) { + return true; + } + int slash = fullName.indexOf('/'); + if (slash <= 0) { + return true; + } + String owner = fullName.substring(0, slash); + return owner.equals(login) + || (organizations != null && organizations.contains(owner)); + } +} diff --git a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/PullRequestListView.java b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/PullRequestListView.java index 7b8c3a3..0ab6bd2 100644 --- a/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/PullRequestListView.java +++ b/org.eclipse.egit.pullrequest/src/org/eclipse/egit/pullrequest/internal/ui/PullRequestListView.java @@ -134,8 +134,13 @@ private void setupForm(Composite parent) { } private void initializeAuthorFilterFromPreferences() { - // Try to set author from preferences - String username = Activator.getDefault().getPreferenceStore().getString(PRPreferences.BITBUCKET_USERNAME); + String provider = Activator.getDefault().getPreferenceStore() + .getString(PRPreferences.PULLREQUEST_PROVIDER_TYPE); + if ("GITHUB".equals(provider)) { //$NON-NLS-1$ + return; + } + String username = Activator.getDefault().getPreferenceStore() + .getString(PRPreferences.BITBUCKET_USERNAME); if (username != null && !username.isEmpty()) { currentAuthorFilter = username; } @@ -151,6 +156,16 @@ protected String getTextForPullRequest(PullRequest pr) { } }); + // Repository Column + TableViewerColumn repositoryColumn = createColumn(layout, + "Repository", 20, SWT.LEFT); //$NON-NLS-1$ + repositoryColumn.setLabelProvider(new PullRequestLabelProvider() { + @Override + protected String getTextForPullRequest(PullRequest pr) { + return repositoryLabel(pr); + } + }); + // Title Column TableViewerColumn titleColumn = createColumn(layout, "Title", 40, //$NON-NLS-1$ SWT.LEFT); @@ -275,6 +290,23 @@ public final String getText(Object element) { protected abstract String getTextForPullRequest(PullRequest pr); } + private static String repositoryLabel(PullRequest pr) { + if (pr.getToRef() == null || pr.getToRef().getRepository() == null) { + return ""; //$NON-NLS-1$ + } + PullRequest.Repository repository = pr.getToRef().getRepository(); + if (repository.getName() != null && !repository.getName().isEmpty()) { + return repository.getName(); + } + if (repository.getProject() != null + && repository.getProject().getKey() != null + && repository.getSlug() != null) { + return repository.getProject().getKey() + "/" //$NON-NLS-1$ + + repository.getSlug(); + } + return repository.getSlug() != null ? repository.getSlug() : ""; //$NON-NLS-1$ + } + private TableViewerColumn createColumn(TableColumnLayout layout, String text, int weight, int style) { TableViewerColumn column = new TableViewerColumn(pullRequestViewer, style); column.getColumn().setText(text); 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 16a8a39..2c0c404 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 @@ -229,7 +229,11 @@ private Composite createGitHubConfiguration(Composite parent) { // Info label with instructions Label infoLabel = new Label(group, SWT.WRAP); infoLabel.setText( - "Create a personal access token (classic) with 'repo' scope at:\nhttps://github.com/settings/tokens\n\nRequired permissions: repo (Full control of private repositories)"); //$NON-NLS-1$ + "Authenticate as a GitHub user with a personal access token " //$NON-NLS-1$ + + "(classic) that has the 'repo' scope:\n" //$NON-NLS-1$ + + "https://github.com/settings/tokens\n\n" //$NON-NLS-1$ + + "The plugin lists pull requests from every repository " //$NON-NLS-1$ + + "you own or belong to."); //$NON-NLS-1$ GridDataFactory.fillDefaults().span(2, 1).hint(400, SWT.DEFAULT) .indent(0, 5).applyTo(infoLabel);