feat: server-side GitHub search for issue/PR pickers (#1352)
Replace local-only filtering in GitHub issue/PR picker dialogs with server-side GitHub Search API queries. Search text is sent as a query parameter to the server, which uses the GitHub Search API (issuesAndPullRequests endpoint) with repo: qualifiers including fork network support. Results are debounced at 350ms to respect API rate limits. - Add query parameter to GitHubAPI issuesList/prsList interface - Server routes use Search API when query is present, standard list endpoint when absent - Fork networks handled via repo:owner/repo OR repo:owner/upstream - PR search fetches full PR details after Search API for head/base/draft fields - Remove local filter memos from all three picker dialogs - Add debounced search effect with abort controller cleanup - Update VS Code backend and webview API for parity - Update search placeholders in all locales Closes #1350 Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
5589ca991a
commit
7b1b3167a4
@@ -947,6 +947,7 @@ export function registerGitHubRoutes(app) {
|
||||
try {
|
||||
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
||||
const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
|
||||
const searchQuery = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory is required' });
|
||||
}
|
||||
@@ -969,6 +970,54 @@ export function registerGitHubRoutes(app) {
|
||||
const effectivePage = Number.isFinite(page) && page > 0 ? page : 1;
|
||||
const reposToQuery = repoNetwork || [{ ...repo, source: 'origin' }];
|
||||
|
||||
const mapIssueSummary = (item, repoRef) => ({
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
url: item.html_url,
|
||||
state: item.state === 'closed' ? 'closed' : 'open',
|
||||
author: item.user ? { login: item.user.login, id: item.user.id, avatarUrl: item.user.avatar_url } : null,
|
||||
labels: Array.isArray(item.labels)
|
||||
? item.labels
|
||||
.map((label) => {
|
||||
if (typeof label === 'string') return null;
|
||||
const name = typeof label?.name === 'string' ? label.name : '';
|
||||
if (!name) return null;
|
||||
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
|
||||
})
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
sourceRepo: { owner: repoRef.owner, repo: repoRef.repo, source: repoRef.source },
|
||||
});
|
||||
|
||||
if (searchQuery) {
|
||||
const repoQualifiers = reposToQuery
|
||||
.map((r) => `repo:${r.owner}/${r.repo}`)
|
||||
.join(' ');
|
||||
const q = `${repoQualifiers} ${searchQuery} type:issue state:open`;
|
||||
try {
|
||||
const searchResult = await octokit.rest.search.issuesAndPullRequests({
|
||||
q,
|
||||
per_page: 50,
|
||||
page: effectivePage,
|
||||
});
|
||||
const totalCount = searchResult.data.total_count;
|
||||
const items = Array.isArray(searchResult.data.items) ? searchResult.data.items : [];
|
||||
const issues = items
|
||||
.filter((item) => !item?.pull_request)
|
||||
.map((item) => {
|
||||
const repoFullName = (item.repository_url || '').replace('https://api.github.com/repos/', '');
|
||||
const matched = reposToQuery.find((r) => `${r.owner}/${r.repo}` === repoFullName);
|
||||
return mapIssueSummary(item, matched || reposToQuery[0]);
|
||||
});
|
||||
const fetchedCount = (effectivePage - 1) * 50 + items.length;
|
||||
const hasMore = fetchedCount < totalCount;
|
||||
return res.json({ connected: true, repo, issues, page: effectivePage, hasMore });
|
||||
} catch (error) {
|
||||
console.error('Failed to search GitHub issues:', error);
|
||||
return res.json({ connected: true, repo, issues: [], page: effectivePage, hasMore: false });
|
||||
}
|
||||
}
|
||||
|
||||
const queryRepo = async (repoRef) => {
|
||||
try {
|
||||
const list = await octokit.rest.issues.listForRepo({
|
||||
@@ -982,24 +1031,7 @@ export function registerGitHubRoutes(app) {
|
||||
const hasMore = /rel="next"/.test(link);
|
||||
const issues = (Array.isArray(list?.data) ? list.data : [])
|
||||
.filter((item) => !item?.pull_request)
|
||||
.map((item) => ({
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
url: item.html_url,
|
||||
state: item.state === 'closed' ? 'closed' : 'open',
|
||||
author: item.user ? { login: item.user.login, id: item.user.id, avatarUrl: item.user.avatar_url } : null,
|
||||
labels: Array.isArray(item.labels)
|
||||
? item.labels
|
||||
.map((label) => {
|
||||
if (typeof label === 'string') return null;
|
||||
const name = typeof label?.name === 'string' ? label.name : '';
|
||||
if (!name) return null;
|
||||
return { name, color: typeof label?.color === 'string' ? label.color : undefined };
|
||||
})
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
sourceRepo: { owner: repoRef.owner, repo: repoRef.repo, source: repoRef.source },
|
||||
}));
|
||||
.map((item) => mapIssueSummary(item, repoRef));
|
||||
return { issues, hasMore };
|
||||
} catch (error) {
|
||||
console.warn(`Failed to list issues for ${repoRef.owner}/${repoRef.repo}:`, error?.message || error);
|
||||
@@ -1128,6 +1160,7 @@ export function registerGitHubRoutes(app) {
|
||||
try {
|
||||
const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : '';
|
||||
const page = typeof req.query?.page === 'string' ? Number(req.query.page) : 1;
|
||||
const searchQuery = typeof req.query?.query === 'string' ? req.query.query.trim() : '';
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error: 'directory is required' });
|
||||
}
|
||||
@@ -1150,6 +1183,86 @@ export function registerGitHubRoutes(app) {
|
||||
const effectivePage = Number.isFinite(page) && page > 0 ? page : 1;
|
||||
const reposToQuery = repoNetwork || [{ ...repo, source: 'origin' }];
|
||||
|
||||
const mapPrSummary = (pr, repoRef) => {
|
||||
const mergedState = pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open');
|
||||
const headRepo = pr.head?.repo
|
||||
? {
|
||||
owner: pr.head.repo.owner?.login,
|
||||
repo: pr.head.repo.name,
|
||||
url: pr.head.repo.html_url,
|
||||
cloneUrl: pr.head.repo.clone_url,
|
||||
sshUrl: pr.head.repo.ssh_url,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
url: pr.html_url,
|
||||
state: mergedState,
|
||||
draft: Boolean(pr.draft),
|
||||
base: pr.base?.ref,
|
||||
head: pr.head?.ref,
|
||||
headSha: pr.head?.sha,
|
||||
mergeable: pr.mergeable,
|
||||
mergeableState: pr.mergeable_state,
|
||||
author: pr.user ? { login: pr.user.login, id: pr.user.id, avatarUrl: pr.user.avatar_url } : null,
|
||||
headLabel: pr.head?.label,
|
||||
headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url
|
||||
? headRepo
|
||||
: null,
|
||||
sourceRepo: { owner: repoRef.owner, repo: repoRef.repo, source: repoRef.source },
|
||||
};
|
||||
};
|
||||
|
||||
if (searchQuery) {
|
||||
const repoQualifiers = reposToQuery
|
||||
.map((r) => `repo:${r.owner}/${r.repo}`)
|
||||
.join(' ');
|
||||
const q = `${repoQualifiers} ${searchQuery} type:pr state:open`;
|
||||
try {
|
||||
const searchResult = await octokit.rest.search.issuesAndPullRequests({
|
||||
q,
|
||||
per_page: 50,
|
||||
page: effectivePage,
|
||||
});
|
||||
const totalCount = searchResult.data.total_count;
|
||||
const items = Array.isArray(searchResult.data.items) ? searchResult.data.items : [];
|
||||
const findRepoForSearchItem = (item) => {
|
||||
const repositoryUrl = typeof item?.repository_url === 'string' ? item.repository_url : '';
|
||||
const match = repositoryUrl.match(/\/repos\/([^/]+)\/([^/]+)$/);
|
||||
if (!match) return reposToQuery[0];
|
||||
return reposToQuery.find((repoRef) => repoRef.owner === match[1] && repoRef.repo === match[2]) || reposToQuery[0];
|
||||
};
|
||||
const prRefs = items
|
||||
.map((item) => ({ number: item.number, repoRef: findRepoForSearchItem(item) }))
|
||||
.filter((ref) => Number.isFinite(ref.number) && ref.number > 0 && ref.repoRef);
|
||||
let prs;
|
||||
if (prRefs.length === 0) {
|
||||
prs = [];
|
||||
} else {
|
||||
const results = await Promise.all(prRefs.map(async ({ number, repoRef }) => {
|
||||
try {
|
||||
const pr = await octokit.rest.pulls.get({
|
||||
owner: repoRef.owner,
|
||||
repo: repoRef.repo,
|
||||
pull_number: number,
|
||||
});
|
||||
return mapPrSummary(pr.data, repoRef);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
prs = results.filter(Boolean);
|
||||
}
|
||||
const fetchedCount = (effectivePage - 1) * 50 + items.length;
|
||||
const hasMore = fetchedCount < totalCount;
|
||||
return res.json({ connected: true, repo, prs, page: effectivePage, hasMore });
|
||||
} catch (error) {
|
||||
console.error('Failed to search GitHub PRs:', error);
|
||||
return res.json({ connected: true, repo, prs: [], page: effectivePage, hasMore: false });
|
||||
}
|
||||
}
|
||||
|
||||
const queryRepo = async (repoRef) => {
|
||||
try {
|
||||
const list = await octokit.rest.pulls.list({
|
||||
@@ -1161,36 +1274,7 @@ export function registerGitHubRoutes(app) {
|
||||
});
|
||||
const link = typeof list?.headers?.link === 'string' ? list.headers.link : '';
|
||||
const hasMore = /rel="next"/.test(link);
|
||||
const prs = (Array.isArray(list?.data) ? list.data : []).map((pr) => {
|
||||
const mergedState = pr.merged_at ? 'merged' : (pr.state === 'closed' ? 'closed' : 'open');
|
||||
const headRepo = pr.head?.repo
|
||||
? {
|
||||
owner: pr.head.repo.owner?.login,
|
||||
repo: pr.head.repo.name,
|
||||
url: pr.head.repo.html_url,
|
||||
cloneUrl: pr.head.repo.clone_url,
|
||||
sshUrl: pr.head.repo.ssh_url,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
url: pr.html_url,
|
||||
state: mergedState,
|
||||
draft: Boolean(pr.draft),
|
||||
base: pr.base?.ref,
|
||||
head: pr.head?.ref,
|
||||
headSha: pr.head?.sha,
|
||||
mergeable: pr.mergeable,
|
||||
mergeableState: pr.mergeable_state,
|
||||
author: pr.user ? { login: pr.user.login, id: pr.user.id, avatarUrl: pr.user.avatar_url } : null,
|
||||
headLabel: pr.head?.label,
|
||||
headRepo: headRepo && headRepo.owner && headRepo.repo && headRepo.url
|
||||
? headRepo
|
||||
: null,
|
||||
sourceRepo: { owner: repoRef.owner, repo: repoRef.repo, source: repoRef.source },
|
||||
};
|
||||
});
|
||||
const prs = (Array.isArray(list?.data) ? list.data : []).map((pr) => mapPrSummary(pr, repoRef));
|
||||
return { prs, hasMore };
|
||||
} catch (error) {
|
||||
console.warn(`Failed to list PRs for ${repoRef.owner}/${repoRef.repo}:`, error?.message || error);
|
||||
|
||||
Reference in New Issue
Block a user