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
@@ -105,12 +105,55 @@ export const listIssues = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
page: number = 1,
|
||||
searchQuery?: string,
|
||||
): Promise<GitHubIssuesListResult> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
return { connected: true, repo: null, issues: [] };
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const q = `repo:${repo.owner}/${repo.repo} ${searchQuery} type:issue state:open`;
|
||||
const url = new URL(`${API_BASE}/search/issues`);
|
||||
url.searchParams.set('q', q);
|
||||
url.searchParams.set('per_page', '50');
|
||||
url.searchParams.set('page', String(page));
|
||||
|
||||
const resp = await githubFetch(url.toString(), accessToken);
|
||||
if (resp.status === 401) {
|
||||
return { connected: false };
|
||||
}
|
||||
|
||||
const json = await jsonOrNull<{ total_count?: number; items?: unknown[] }>(resp);
|
||||
if (!resp.ok || !json) {
|
||||
throw new Error('Failed to search issues');
|
||||
}
|
||||
|
||||
const totalCount = typeof json.total_count === 'number' ? json.total_count : 0;
|
||||
const items = Array.isArray(json.items) ? json.items : [];
|
||||
const issues = items
|
||||
.map((entry) => {
|
||||
const rec = entry && typeof entry === 'object' ? (entry as JsonRecord) : null;
|
||||
if (!rec || rec.pull_request) return null;
|
||||
const number = typeof rec.number === 'number' ? rec.number : 0;
|
||||
if (!number) return null;
|
||||
const state = readString(rec.state) === 'closed' ? 'closed' : 'open';
|
||||
return {
|
||||
number,
|
||||
title: readString(rec.title) || '',
|
||||
url: readString(rec.html_url) || '',
|
||||
state,
|
||||
author: mapUser(rec.user),
|
||||
labels: mapLabels(rec.labels),
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as GitHubIssuesListResult['issues'];
|
||||
|
||||
const fetchedCount = (page - 1) * 50 + items.length;
|
||||
const hasMore = fetchedCount < totalCount;
|
||||
return { connected: true, repo, issues: issues || [], page, hasMore };
|
||||
}
|
||||
|
||||
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/issues`);
|
||||
url.searchParams.set('state', 'open');
|
||||
url.searchParams.set('per_page', '50');
|
||||
|
||||
@@ -285,12 +285,77 @@ export const listPullRequests = async (
|
||||
accessToken: string,
|
||||
directory: string,
|
||||
page: number = 1,
|
||||
searchQuery?: string,
|
||||
): Promise<GitHubPullRequestsListResult> => {
|
||||
const repo = await resolveRepoFromDirectory(directory);
|
||||
if (!repo) {
|
||||
return { connected: true, repo: null, prs: [] };
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const q = `repo:${repo.owner}/${repo.repo} ${searchQuery} type:pr state:open`;
|
||||
const searchUrl = new URL(`${API_BASE}/search/issues`);
|
||||
searchUrl.searchParams.set('q', q);
|
||||
searchUrl.searchParams.set('per_page', '50');
|
||||
searchUrl.searchParams.set('page', String(page));
|
||||
|
||||
const searchResp = await githubFetch(searchUrl.toString(), accessToken);
|
||||
if (searchResp.status === 401) return { connected: false };
|
||||
|
||||
const searchJson = await jsonOrNull<{ total_count?: number; items?: unknown[] }>(searchResp);
|
||||
if (!searchResp.ok || !searchJson) {
|
||||
throw new Error('Failed to search PRs');
|
||||
}
|
||||
|
||||
const totalCount = typeof searchJson.total_count === 'number' ? searchJson.total_count : 0;
|
||||
const searchItems = Array.isArray(searchJson.items) ? searchJson.items : [];
|
||||
const prNumbers = searchItems.map((item) => {
|
||||
const rec = item && typeof item === 'object' ? (item as JsonRecord) : null;
|
||||
return typeof rec?.number === 'number' ? rec.number : 0;
|
||||
}).filter((n) => n > 0);
|
||||
|
||||
let prs: GitHubPullRequestSummary[] = [];
|
||||
if (prNumbers.length > 0) {
|
||||
const entries = await Promise.all(prNumbers.map(async (number) => {
|
||||
const prUrl = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls/${number}`);
|
||||
const prResp = await githubFetch(prUrl.toString(), accessToken);
|
||||
if (prResp.status === 401) return 'unauthorized' as const;
|
||||
if (!prResp.ok) return null;
|
||||
return jsonOrNull<JsonRecord>(prResp);
|
||||
}));
|
||||
if (entries.includes('unauthorized')) return { connected: false };
|
||||
prs = entries
|
||||
.filter((entry): entry is JsonRecord => Boolean(entry) && entry !== 'unauthorized')
|
||||
.map((rec) => {
|
||||
const number = typeof rec?.number === 'number' ? rec.number : 0;
|
||||
const mergedAt = readString(rec?.merged_at);
|
||||
const stateRaw = readString(rec?.state);
|
||||
const state = mergedAt ? 'merged' : (stateRaw === 'closed' ? 'closed' : 'open');
|
||||
const base = rec?.base && typeof rec.base === 'object' ? (rec.base as JsonRecord) : null;
|
||||
const head = rec?.head && typeof rec.head === 'object' ? (rec.head as JsonRecord) : null;
|
||||
return {
|
||||
number,
|
||||
title: readString(rec?.title) || '',
|
||||
url: readString(rec?.html_url) || '',
|
||||
state,
|
||||
draft: Boolean(rec?.draft),
|
||||
base: readString(base?.ref) || '',
|
||||
head: readString(head?.ref) || '',
|
||||
headSha: readString(head?.sha) || undefined,
|
||||
mergeable: typeof rec?.mergeable === 'boolean' ? rec.mergeable : null,
|
||||
mergeableState: readString(rec?.mergeable_state) || undefined,
|
||||
author: mapUser(rec?.user),
|
||||
headLabel: readString(head?.label) || undefined,
|
||||
headRepo: mapHeadRepo(head?.repo),
|
||||
} as GitHubPullRequestSummary;
|
||||
});
|
||||
}
|
||||
|
||||
const fetchedCount = (page - 1) * 50 + searchItems.length;
|
||||
const hasMore = fetchedCount < totalCount;
|
||||
return { connected: true, repo, prs, page, hasMore };
|
||||
}
|
||||
|
||||
const url = new URL(`${API_BASE}/repos/${repo.owner}/${repo.repo}/pulls`);
|
||||
url.searchParams.set('state', 'open');
|
||||
url.searchParams.set('per_page', '50');
|
||||
|
||||
@@ -43,15 +43,15 @@ export const createVSCodeGitHubAPI = (): GitHubAPI => ({
|
||||
prReady: async (payload: GitHubPullRequestReadyInput) =>
|
||||
sendBridgeMessage<GitHubPullRequestReadyResult>('api:github/pr:ready', payload),
|
||||
|
||||
issuesList: async (directory: string, options?: { page?: number }) =>
|
||||
sendBridgeMessage<GitHubIssuesListResult>('api:github/issues:list', { directory, page: options?.page ?? 1 }),
|
||||
issuesList: async (directory: string, options?: { page?: number; query?: string }) =>
|
||||
sendBridgeMessage<GitHubIssuesListResult>('api:github/issues:list', { directory, page: options?.page ?? 1, query: options?.query ?? '' }),
|
||||
issueGet: async (directory: string, number: number, options?: { sourceRepo?: { owner: string; repo: string } | null }) =>
|
||||
sendBridgeMessage<GitHubIssueGetResult>('api:github/issues:get', { directory, number, sourceRepo: options?.sourceRepo ?? null }),
|
||||
issueComments: async (directory: string, number: number, options?: { sourceRepo?: { owner: string; repo: string } | null }) =>
|
||||
sendBridgeMessage<GitHubIssueCommentsResult>('api:github/issues:comments', { directory, number, sourceRepo: options?.sourceRepo ?? null }),
|
||||
|
||||
prsList: async (directory: string, options?: { page?: number }) =>
|
||||
sendBridgeMessage<GitHubPullRequestsListResult>('api:github/pulls:list', { directory, page: options?.page ?? 1 }),
|
||||
prsList: async (directory: string, options?: { page?: number; query?: string }) =>
|
||||
sendBridgeMessage<GitHubPullRequestsListResult>('api:github/pulls:list', { directory, page: options?.page ?? 1, query: options?.query ?? '' }),
|
||||
prContext: async (directory: string, number: number, options?: { includeDiff?: boolean; includeCheckDetails?: boolean; sourceRepo?: { owner: string; repo: string } | null }) =>
|
||||
sendBridgeMessage<GitHubPullRequestContextResult>('api:github/pulls:context', {
|
||||
directory,
|
||||
|
||||
Reference in New Issue
Block a user