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
@@ -18,6 +18,7 @@ import { validateWorktreeCreate } from '@/lib/worktrees/worktreeManager';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import type {
|
||||
GitHubIssue,
|
||||
GitHubIssueSummary,
|
||||
@@ -80,8 +81,9 @@ export function GitHubIntegrationDialog({
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [hasMore, setHasMore] = React.useState(false);
|
||||
|
||||
// Load GitHub data
|
||||
const loadData = React.useCallback(async () => {
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 350);
|
||||
|
||||
const loadData = React.useCallback(async (query?: string) => {
|
||||
if (!projectDirectory || !github) return;
|
||||
if (githubAuthChecked && githubAuthStatus?.connected === false) return;
|
||||
|
||||
@@ -92,7 +94,7 @@ export function GitHubIntegrationDialog({
|
||||
|
||||
try {
|
||||
if (activeTab === 'issues' && github.issuesList) {
|
||||
const result = await github.issuesList(projectDirectory, { page: 1 });
|
||||
const result = await github.issuesList(projectDirectory, { page: 1, query });
|
||||
if (result.connected === false) {
|
||||
setError(t('session.githubIntegration.error.notConnected'));
|
||||
setIssues([]);
|
||||
@@ -102,7 +104,7 @@ export function GitHubIntegrationDialog({
|
||||
setHasMore(Boolean(result.hasMore));
|
||||
}
|
||||
} else if (activeTab === 'prs' && github.prsList) {
|
||||
const result = await github.prsList(projectDirectory, { page: 1 });
|
||||
const result = await github.prsList(projectDirectory, { page: 1, query });
|
||||
if (result.connected === false) {
|
||||
setError(t('session.githubIntegration.error.notConnected'));
|
||||
setPrs([]);
|
||||
@@ -119,7 +121,66 @@ export function GitHubIntegrationDialog({
|
||||
}
|
||||
}, [projectDirectory, github, githubAuthChecked, githubAuthStatus, activeTab, t]);
|
||||
|
||||
// Load more data
|
||||
React.useEffect(() => {
|
||||
if (!open || !projectDirectory) return;
|
||||
if (githubAuthChecked && githubAuthStatus?.connected === false) return;
|
||||
if (!github) return;
|
||||
if (!debouncedSearchQuery.trim()) {
|
||||
void loadData();
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setPage(1);
|
||||
setHasMore(false);
|
||||
|
||||
const apiCall = activeTab === 'issues' && github.issuesList
|
||||
? github.issuesList(projectDirectory, { page: 1, query: debouncedSearchQuery.trim() })
|
||||
: activeTab === 'prs' && github.prsList
|
||||
? github.prsList(projectDirectory, { page: 1, query: debouncedSearchQuery.trim() })
|
||||
: null;
|
||||
|
||||
if (!apiCall) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
apiCall
|
||||
.then((result) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if ('issues' in result) {
|
||||
if (result.connected === false) {
|
||||
setError(t('session.githubIntegration.error.notConnected'));
|
||||
setIssues([]);
|
||||
} else {
|
||||
setIssues(result.issues ?? []);
|
||||
setPage(result.page ?? 1);
|
||||
setHasMore(Boolean(result.hasMore));
|
||||
}
|
||||
} else if ('prs' in result) {
|
||||
if (result.connected === false) {
|
||||
setError(t('session.githubIntegration.error.notConnected'));
|
||||
setPrs([]);
|
||||
} else {
|
||||
setPrs(result.prs ?? []);
|
||||
setPage(result.page ?? 1);
|
||||
setHasMore(Boolean(result.hasMore));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(err instanceof Error ? err.message : t('session.githubIntegration.error.loadDataFailed'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [open, projectDirectory, github, githubAuthChecked, githubAuthStatus, activeTab, debouncedSearchQuery, loadData, t]);
|
||||
|
||||
const loadMore = React.useCallback(async () => {
|
||||
if (!projectDirectory || !github) return;
|
||||
if (loading || loadingMore) return;
|
||||
@@ -131,14 +192,18 @@ export function GitHubIntegrationDialog({
|
||||
const nextPage = page + 1;
|
||||
|
||||
if (activeTab === 'issues' && github.issuesList) {
|
||||
const result = await github.issuesList(projectDirectory, { page: nextPage });
|
||||
const result = debouncedSearchQuery.trim()
|
||||
? await github.issuesList(projectDirectory, { page: nextPage, query: debouncedSearchQuery.trim() })
|
||||
: await github.issuesList(projectDirectory, { page: nextPage });
|
||||
if (result.connected !== false) {
|
||||
setIssues(prev => [...prev, ...(result.issues ?? [])]);
|
||||
setPage(result.page ?? nextPage);
|
||||
setHasMore(Boolean(result.hasMore));
|
||||
}
|
||||
} else if (activeTab === 'prs' && github.prsList) {
|
||||
const result = await github.prsList(projectDirectory, { page: nextPage });
|
||||
const result = debouncedSearchQuery.trim()
|
||||
? await github.prsList(projectDirectory, { page: nextPage, query: debouncedSearchQuery.trim() })
|
||||
: await github.prsList(projectDirectory, { page: nextPage });
|
||||
if (result.connected !== false) {
|
||||
setPrs(prev => [...prev, ...(result.prs ?? [])]);
|
||||
setPage(result.page ?? nextPage);
|
||||
@@ -150,7 +215,7 @@ export function GitHubIntegrationDialog({
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [projectDirectory, github, activeTab, page, hasMore, loading, loadingMore]);
|
||||
}, [projectDirectory, github, activeTab, page, hasMore, loading, loadingMore, debouncedSearchQuery]);
|
||||
|
||||
// Reset state when dialog opens/closes
|
||||
React.useEffect(() => {
|
||||
@@ -215,25 +280,6 @@ export function GitHubIntegrationDialog({
|
||||
});
|
||||
}, [open, activeTab, prs, validateBranch]);
|
||||
|
||||
// Filtered results
|
||||
const filteredIssues = React.useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) return issues;
|
||||
return issues.filter(issue => {
|
||||
if (String(issue.number) === q.replace(/^#/, '')) return true;
|
||||
return issue.title.toLowerCase().includes(q);
|
||||
});
|
||||
}, [issues, searchQuery]);
|
||||
|
||||
const filteredPrs = React.useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (!q) return prs;
|
||||
return prs.filter(pr => {
|
||||
if (String(pr.number) === q.replace(/^#/, '')) return true;
|
||||
return pr.title.toLowerCase().includes(q);
|
||||
});
|
||||
}, [prs, searchQuery]);
|
||||
|
||||
// GitHub connection check
|
||||
const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true;
|
||||
|
||||
@@ -337,8 +383,8 @@ export function GitHubIntegrationDialog({
|
||||
{/* Issues List */}
|
||||
{!loading && !error && activeTab === 'issues' && (
|
||||
<div className="space-y-0.5 min-h-full">
|
||||
{filteredIssues.length > 0 ? (
|
||||
filteredIssues.map(issue => (
|
||||
{issues.length > 0 ? (
|
||||
issues.map(issue => (
|
||||
<button
|
||||
key={`${issue.sourceRepo?.owner ?? ''}-${issue.sourceRepo?.repo ?? ''}-${issue.number}`}
|
||||
onClick={() => handleSelectIssue(issue)}
|
||||
@@ -391,8 +437,8 @@ export function GitHubIntegrationDialog({
|
||||
{/* PRs List */}
|
||||
{!loading && !error && activeTab === 'prs' && (
|
||||
<div className="space-y-0.5 min-h-full">
|
||||
{filteredPrs.length > 0 ? (
|
||||
filteredPrs.map(pr => {
|
||||
{prs.length > 0 ? (
|
||||
prs.map(pr => {
|
||||
const blocked = isPrBlocked(pr);
|
||||
const validation = pr.head ? validations.get(pr.head) : undefined;
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import { parseModelIdentifier } from '@/lib/modelIdentifier';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
|
||||
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult, GitHubIssueSummary, GitHubRepoSelector } from '@/lib/api/types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
@@ -99,6 +100,10 @@ export function GitHubIssuePickerDialog({
|
||||
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const directNumber = React.useMemo(() => parseIssueNumber(query), [query]);
|
||||
const debouncedQuery = useDebouncedValue(query, 350);
|
||||
const isTextSearch = debouncedQuery.trim().length > 0 && !directNumber;
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
if (!projectDirectory) {
|
||||
setResult(null);
|
||||
@@ -137,6 +142,38 @@ export function GitHubIssuePickerDialog({
|
||||
}
|
||||
}, [github, githubAuthChecked, githubAuthStatus, projectDirectory, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !projectDirectory) return;
|
||||
if (githubAuthChecked && githubAuthStatus?.connected === false) return;
|
||||
if (!github?.issuesList) return;
|
||||
if (!debouncedQuery.trim() || directNumber) {
|
||||
void refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
github.issuesList(projectDirectory, { page: 1, query: debouncedQuery.trim() })
|
||||
.then((next) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setResult(next);
|
||||
setIssues(next.issues ?? []);
|
||||
setPage(next.page ?? 1);
|
||||
setHasMore(Boolean(next.hasMore));
|
||||
})
|
||||
.catch((e) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [open, projectDirectory, github, githubAuthChecked, githubAuthStatus, debouncedQuery, directNumber, refresh, t]);
|
||||
|
||||
const loadMore = React.useCallback(async () => {
|
||||
if (!projectDirectory) return;
|
||||
if (!github?.issuesList) return;
|
||||
@@ -146,7 +183,9 @@ export function GitHubIssuePickerDialog({
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const nextPage = page + 1;
|
||||
const next = await github.issuesList(projectDirectory, { page: nextPage });
|
||||
const next = isTextSearch
|
||||
? await github.issuesList(projectDirectory, { page: nextPage, query: debouncedQuery.trim() })
|
||||
: await github.issuesList(projectDirectory, { page: nextPage });
|
||||
setResult(next);
|
||||
setIssues((prev) => [...prev, ...(next.issues ?? [])]);
|
||||
setPage(next.page ?? nextPage);
|
||||
@@ -157,7 +196,7 @@ export function GitHubIssuePickerDialog({
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory, t]);
|
||||
}, [github, hasMore, isLoading, isLoadingMore, isTextSearch, debouncedQuery, page, projectDirectory, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -194,17 +233,6 @@ export function GitHubIssuePickerDialog({
|
||||
setSettingsDialogOpen(true);
|
||||
}, [setSettingsDialogOpen, setSettingsPage]);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return issues;
|
||||
return issues.filter((issue) => {
|
||||
if (String(issue.number) === q.replace(/^#/, '')) return true;
|
||||
return issue.title.toLowerCase().includes(q);
|
||||
});
|
||||
}, [issues, query]);
|
||||
|
||||
const directNumber = React.useMemo(() => parseIssueNumber(query), [query]);
|
||||
|
||||
const resolveDefaultAgentName = React.useCallback((): string | undefined => {
|
||||
const configState = useConfigStore.getState();
|
||||
const visibleAgents = configState.getVisibleAgents();
|
||||
@@ -519,11 +547,11 @@ export function GitHubIssuePickerDialog({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{filtered.length === 0 && !isLoading && connected && github && projectDirectory ? (
|
||||
<div className="text-center text-muted-foreground py-8">{query ? t('session.githubIssuePicker.empty.noIssuesFound') : t('session.githubIssuePicker.empty.noOpenIssuesFound')}</div>
|
||||
{issues.length === 0 && !isLoading && connected && github && projectDirectory ? (
|
||||
<div className="text-center text-muted-foreground py-8">{debouncedQuery.trim() ? t('session.githubIssuePicker.empty.noIssuesFound') : t('session.githubIssuePicker.empty.noOpenIssuesFound')}</div>
|
||||
) : null}
|
||||
|
||||
{filtered.map((issue) => (
|
||||
{issues.map((issue) => (
|
||||
<div
|
||||
key={`${issue.sourceRepo?.owner ?? ''}-${issue.sourceRepo?.repo ?? ''}-${issue.number}`}
|
||||
className={cn(
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import type { GitHubPullRequestContextResult, GitHubPullRequestSummary, GitHubPullRequestsListResult, GitHubRepoSelector } from '@/lib/api/types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
@@ -88,6 +89,10 @@ export function GitHubPrPickerDialog({
|
||||
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const directNumber = React.useMemo(() => parsePrNumber(query), [query]);
|
||||
const debouncedQuery = useDebouncedValue(query, 350);
|
||||
const isTextSearch = debouncedQuery.trim().length > 0 && !directNumber;
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
if (!projectDirectory) {
|
||||
setResult(null);
|
||||
@@ -126,6 +131,38 @@ export function GitHubPrPickerDialog({
|
||||
}
|
||||
}, [github, githubAuthChecked, githubAuthStatus, projectDirectory, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !projectDirectory) return;
|
||||
if (githubAuthChecked && githubAuthStatus?.connected === false) return;
|
||||
if (!github?.prsList) return;
|
||||
if (!debouncedQuery.trim() || directNumber) {
|
||||
void refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
github.prsList(projectDirectory, { page: 1, query: debouncedQuery.trim() })
|
||||
.then((next) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setResult(next);
|
||||
setPrs(next.prs ?? []);
|
||||
setPage(next.page ?? 1);
|
||||
setHasMore(Boolean(next.hasMore));
|
||||
})
|
||||
.catch((e) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [open, projectDirectory, github, githubAuthChecked, githubAuthStatus, debouncedQuery, directNumber, refresh, t]);
|
||||
|
||||
const loadMore = React.useCallback(async () => {
|
||||
if (!projectDirectory) return;
|
||||
if (!github?.prsList) return;
|
||||
@@ -135,7 +172,9 @@ export function GitHubPrPickerDialog({
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const nextPage = page + 1;
|
||||
const next = await github.prsList(projectDirectory, { page: nextPage });
|
||||
const next = isTextSearch
|
||||
? await github.prsList(projectDirectory, { page: nextPage, query: debouncedQuery.trim() })
|
||||
: await github.prsList(projectDirectory, { page: nextPage });
|
||||
setResult(next);
|
||||
setPrs((prev) => [...prev, ...(next.prs ?? [])]);
|
||||
setPage(next.page ?? nextPage);
|
||||
@@ -146,7 +185,7 @@ export function GitHubPrPickerDialog({
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory, t]);
|
||||
}, [github, hasMore, isLoading, isLoadingMore, isTextSearch, debouncedQuery, page, projectDirectory, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -182,17 +221,6 @@ export function GitHubPrPickerDialog({
|
||||
setSettingsDialogOpen(true);
|
||||
}, [setSettingsDialogOpen, setSettingsPage]);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return prs;
|
||||
return prs.filter((pr) => {
|
||||
if (String(pr.number) === q.replace(/^#/, '')) return true;
|
||||
return pr.title.toLowerCase().includes(q);
|
||||
});
|
||||
}, [prs, query]);
|
||||
|
||||
const directNumber = React.useMemo(() => parsePrNumber(query), [query]);
|
||||
|
||||
const attachPr = React.useCallback(async (prNumber: number, sourceRepo?: GitHubRepoSelector | null) => {
|
||||
if (!projectDirectory) {
|
||||
toast.error(t('session.githubPrPicker.error.noActiveProject'));
|
||||
@@ -341,11 +369,11 @@ export function GitHubPrPickerDialog({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{filtered.length === 0 && !isLoading && connected && github && projectDirectory ? (
|
||||
<div className="text-center text-muted-foreground py-8">{query ? t('session.githubPrPicker.empty.noPullRequestsFound') : t('session.githubPrPicker.empty.noOpenPullRequestsFound')}</div>
|
||||
{prs.length === 0 && !isLoading && connected && github && projectDirectory ? (
|
||||
<div className="text-center text-muted-foreground py-8">{debouncedQuery.trim() ? t('session.githubPrPicker.empty.noPullRequestsFound') : t('session.githubPrPicker.empty.noOpenPullRequestsFound')}</div>
|
||||
) : null}
|
||||
|
||||
{filtered.map((pr) => (
|
||||
{prs.map((pr) => (
|
||||
<div
|
||||
key={`${pr.sourceRepo?.owner ?? ''}-${pr.sourceRepo?.repo ?? ''}-${pr.number}`}
|
||||
className={cn(
|
||||
|
||||
@@ -1076,14 +1076,14 @@ export interface GitHubAPI {
|
||||
prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult>;
|
||||
prReady(payload: GitHubPullRequestReadyInput): Promise<GitHubPullRequestReadyResult>;
|
||||
|
||||
prsList(directory: string, options?: { page?: number }): Promise<GitHubPullRequestsListResult>;
|
||||
prsList(directory: string, options?: { page?: number; query?: string }): Promise<GitHubPullRequestsListResult>;
|
||||
prContext(
|
||||
directory: string,
|
||||
number: number,
|
||||
options?: { includeDiff?: boolean; includeCheckDetails?: boolean; sourceRepo?: GitHubRepoSelector | null }
|
||||
): Promise<GitHubPullRequestContextResult>;
|
||||
|
||||
issuesList(directory: string, options?: { page?: number }): Promise<GitHubIssuesListResult>;
|
||||
issuesList(directory: string, options?: { page?: number; query?: string }): Promise<GitHubIssuesListResult>;
|
||||
issueGet(directory: string, number: number, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise<GitHubIssueGetResult>;
|
||||
issueComments(directory: string, number: number, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise<GitHubIssueCommentsResult>;
|
||||
repoUpstream(directory: string): Promise<GitHubRepoUpstreamResult>;
|
||||
|
||||
@@ -1437,7 +1437,7 @@ export const dict = {
|
||||
'session.githubIssuePicker.title.createSession': 'New Session From GitHub Issue',
|
||||
'session.githubIssuePicker.description.select': 'Select an issue to link to this session.',
|
||||
'session.githubIssuePicker.description.createSession': 'Seeds a new session with hidden issue context (title/body/labels/comments).',
|
||||
'session.githubIssuePicker.searchPlaceholder': 'Search by title or #123, or paste issue URL',
|
||||
'session.githubIssuePicker.searchPlaceholder': 'Search using GitHub code search syntax',
|
||||
'session.githubIssuePicker.empty.noActiveProject': 'No active project selected.',
|
||||
'session.githubIssuePicker.empty.runtimeUnavailable': 'GitHub runtime API unavailable.',
|
||||
'session.githubIssuePicker.empty.notConnected': 'GitHub not connected. Connect your GitHub account in settings.',
|
||||
@@ -1464,7 +1464,7 @@ export const dict = {
|
||||
'session.githubPrPicker.toast.loadDetailsFailed': 'Failed to load pull request details',
|
||||
'session.githubPrPicker.title': 'Link GitHub Pull Request',
|
||||
'session.githubPrPicker.description': 'Select a pull request to attach review context to this message.',
|
||||
'session.githubPrPicker.searchPlaceholder': 'Search by title or #123, or paste pull request URL',
|
||||
'session.githubPrPicker.searchPlaceholder': 'Search using GitHub code search syntax',
|
||||
'session.githubPrPicker.includeDiffAria': 'Include PR diff in attached context',
|
||||
'session.githubPrPicker.includeDiff': 'Include PR diff',
|
||||
'session.githubPrPicker.empty.noActiveProject': 'No active project selected.',
|
||||
@@ -1531,8 +1531,8 @@ export const dict = {
|
||||
'session.githubIntegration.connect.title': 'Connect to GitHub',
|
||||
'session.githubIntegration.connect.description': 'Link issues or pull requests to auto-fill worktree details',
|
||||
'session.githubIntegration.connect.action': 'Connect GitHub',
|
||||
'session.githubIntegration.search.issuesPlaceholder': 'Search issues or enter #123...',
|
||||
'session.githubIntegration.search.prsPlaceholder': 'Search PRs or enter #456...',
|
||||
'session.githubIntegration.search.issuesPlaceholder': 'Search using GitHub code search syntax',
|
||||
'session.githubIntegration.search.prsPlaceholder': 'Search using GitHub code search syntax',
|
||||
'session.githubIntegration.empty.noIssuesFound': 'No issues found',
|
||||
'session.githubIntegration.empty.noPullRequestsFound': 'No pull requests found',
|
||||
'session.githubIntegration.actions.loadMore': 'Load more',
|
||||
|
||||
@@ -1403,7 +1403,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"session.githubIssuePicker.title.createSession": "Nueva sesión desde issue de GitHub",
|
||||
"session.githubIssuePicker.description.select": "Selecciona un issue para vincularlo a esta sesión.",
|
||||
"session.githubIssuePicker.description.createSession": "Inicia una nueva sesión con contexto oculto del issue (título/cuerpo/etiquetas/comentarios).",
|
||||
"session.githubIssuePicker.searchPlaceholder": "Buscar por título o #123, o pegar la URL del issue",
|
||||
"session.githubIssuePicker.searchPlaceholder": "Buscar usando la sintaxis de búsqueda de código de GitHub",
|
||||
"session.githubIssuePicker.empty.noActiveProject": "No hay ningún proyecto activo seleccionado.",
|
||||
"session.githubIssuePicker.empty.runtimeUnavailable": "API de runtime de GitHub no disponible.",
|
||||
"session.githubIssuePicker.empty.notConnected": "GitHub no está conectado. Conecta tu cuenta de GitHub en configuración.",
|
||||
@@ -1430,7 +1430,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"session.githubPrPicker.toast.loadDetailsFailed": "No se pudieron cargar los detalles de la PR",
|
||||
"session.githubPrPicker.title": "Vincular PR de GitHub",
|
||||
"session.githubPrPicker.description": "Selecciona una PR para adjuntar contexto de revisión a este mensaje.",
|
||||
"session.githubPrPicker.searchPlaceholder": "Buscar por título o #123, o pegar la URL de la PR",
|
||||
"session.githubPrPicker.searchPlaceholder": "Buscar usando la sintaxis de búsqueda de código de GitHub",
|
||||
"session.githubPrPicker.includeDiffAria": "Incluir diff de la PR en el contexto adjunto",
|
||||
"session.githubPrPicker.includeDiff": "Incluir diff de la PR",
|
||||
"session.githubPrPicker.empty.noActiveProject": "No hay ningún proyecto activo seleccionado.",
|
||||
@@ -1497,8 +1497,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"session.githubIntegration.connect.title": "Conectar a GitHub",
|
||||
"session.githubIntegration.connect.description": "Vincula issues o PR para rellenar automáticamente los detalles del worktree",
|
||||
"session.githubIntegration.connect.action": "Conectar a GitHub",
|
||||
"session.githubIntegration.search.issuesPlaceholder": "Buscar issues o introducir #123...",
|
||||
"session.githubIntegration.search.prsPlaceholder": "Buscar PRs o introducir #456...",
|
||||
"session.githubIntegration.search.issuesPlaceholder": "Buscar usando la sintaxis de búsqueda de código de GitHub",
|
||||
"session.githubIntegration.search.prsPlaceholder": "Buscar usando la sintaxis de búsqueda de código de GitHub",
|
||||
"session.githubIntegration.empty.noIssuesFound": "No se encontraron issues",
|
||||
"session.githubIntegration.empty.noPullRequestsFound": "No se encontraron PR",
|
||||
"session.githubIntegration.actions.loadMore": "Cargar más",
|
||||
|
||||
@@ -1439,7 +1439,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'session.githubIssuePicker.title.createSession': 'GitHub 이슈로 새 세션 만들기',
|
||||
'session.githubIssuePicker.description.select': '이 세션에 연결할 이슈를 선택하세요.',
|
||||
'session.githubIssuePicker.description.createSession': '제목, 본문, 라벨, 댓글을 숨겨진 이슈 컨텍스트로 새 세션에 추가합니다.',
|
||||
'session.githubIssuePicker.searchPlaceholder': '제목 또는 #123으로 검색하거나 이슈 URL을 붙여넣으세요',
|
||||
'session.githubIssuePicker.searchPlaceholder': 'GitHub 코드 검색 구문을 사용하여 검색',
|
||||
'session.githubIssuePicker.empty.noActiveProject': '선택된 활성 프로젝트가 없습니다',
|
||||
'session.githubIssuePicker.empty.runtimeUnavailable': 'GitHub 런타임 API를 사용할 수 없습니다.',
|
||||
'session.githubIssuePicker.empty.notConnected': 'GitHub에 연결되지 않았습니다. 설정에서 GitHub 계정을 연결하세요.',
|
||||
@@ -1466,7 +1466,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'session.githubPrPicker.toast.loadDetailsFailed': 'PR 상세 정보를 불러오지 못했습니다',
|
||||
'session.githubPrPicker.title': 'GitHub PR 연결',
|
||||
'session.githubPrPicker.description': '이 메시지에 리뷰 컨텍스트로 첨부할 PR을 선택하세요.',
|
||||
'session.githubPrPicker.searchPlaceholder': '제목 또는 #123으로 검색하거나 PR URL을 붙여넣으세요',
|
||||
'session.githubPrPicker.searchPlaceholder': 'GitHub 코드 검색 구문을 사용하여 검색',
|
||||
'session.githubPrPicker.includeDiffAria': 'PR 변경사항을 첨부 컨텍스트에 포함',
|
||||
'session.githubPrPicker.includeDiff': 'PR 변경사항 포함',
|
||||
'session.githubPrPicker.empty.noActiveProject': '선택된 활성 프로젝트가 없습니다',
|
||||
@@ -1533,8 +1533,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'session.githubIntegration.connect.title': 'GitHub에 연결',
|
||||
'session.githubIntegration.connect.description': '이슈 또는 PR을 연결해 워크트리 정보를 자동으로 채웁니다',
|
||||
'session.githubIntegration.connect.action': 'GitHub 연결',
|
||||
'session.githubIntegration.search.issuesPlaceholder': '이슈 검색 또는 #123 입력…',
|
||||
'session.githubIntegration.search.prsPlaceholder': 'PR 검색 또는 #456 입력…',
|
||||
'session.githubIntegration.search.issuesPlaceholder': 'GitHub 코드 검색 구문을 사용하여 검색',
|
||||
'session.githubIntegration.search.prsPlaceholder': 'GitHub 코드 검색 구문을 사용하여 검색',
|
||||
'session.githubIntegration.empty.noIssuesFound': '이슈 없음',
|
||||
'session.githubIntegration.empty.noPullRequestsFound': 'PR이 없습니다',
|
||||
'session.githubIntegration.actions.loadMore': '더 불러오기',
|
||||
|
||||
@@ -513,8 +513,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'session.githubIntegration.connect.title': 'Połącz z GitHub',
|
||||
'session.githubIntegration.connect.description': 'Połącz zagadnienia lub pull requesty aby auto-wypełnić szczegóły drzewa pracy',
|
||||
'session.githubIntegration.connect.action': 'Połącz GitHub',
|
||||
'session.githubIntegration.search.issuesPlaceholder': 'Szukaj zagadnień lub wpisz #123...',
|
||||
'session.githubIntegration.search.prsPlaceholder': 'Szukaj PR lub wpisz #456...',
|
||||
'session.githubIntegration.search.issuesPlaceholder': 'Szukaj używając składni wyszukiwania kodu GitHub',
|
||||
'session.githubIntegration.search.prsPlaceholder': 'Szukaj używając składni wyszukiwania kodu GitHub',
|
||||
'session.githubIntegration.empty.noIssuesFound': 'Nie znaleziono zagadnień',
|
||||
'session.githubIntegration.empty.noPullRequestsFound': 'Nie znaleziono pull requestów',
|
||||
'session.githubIntegration.actions.loadMore': 'Załaduj więcej',
|
||||
@@ -2227,7 +2227,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'session.githubIssuePicker.error.runtimeUnavailable': 'API środowiska GitHub jest niedostępne',
|
||||
'session.githubIssuePicker.loading.issues': 'Ładowanie zgłoszeń...',
|
||||
'session.githubIssuePicker.loading.more': 'Ładowanie...',
|
||||
'session.githubIssuePicker.searchPlaceholder': 'Szukaj po tytule lub #123, albo wklej URL zgłoszenia',
|
||||
'session.githubIssuePicker.searchPlaceholder': 'Szukaj używając składni wyszukiwania kodu GitHub',
|
||||
'session.githubIssuePicker.title.createSession': 'Nowa sesja ze zgłoszenia GitHub',
|
||||
'session.githubIssuePicker.title.select': 'Połącz zgłoszenie GitHub',
|
||||
'session.githubIssuePicker.toast.loadIssueDetailsFailed': 'Nie udało się załadować szczegółów zgłoszenia',
|
||||
@@ -2255,7 +2255,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'session.githubPrPicker.includeDiffAria': 'Dołącz diff PR do załączonego kontekstu',
|
||||
'session.githubPrPicker.loading.more': 'Ładowanie...',
|
||||
'session.githubPrPicker.loading.pullRequests': 'Ładowanie pull requestów...',
|
||||
'session.githubPrPicker.searchPlaceholder': 'Szukaj po tytule lub #123, albo wklej URL pull requesta',
|
||||
'session.githubPrPicker.searchPlaceholder': 'Szukaj używając składni wyszukiwania kodu GitHub',
|
||||
'session.githubPrPicker.title': 'Połącz pull request GitHub',
|
||||
'session.githubPrPicker.toast.loadDetailsFailed': 'Nie udało się załadować szczegółów pull requesta',
|
||||
'session.githubPrPicker.toast.loadMoreFailed': 'Nie udało się załadować kolejnych pull requestów',
|
||||
|
||||
@@ -1403,7 +1403,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"session.githubIssuePicker.title.createSession": "Nova sessão de issue de GitHub",
|
||||
"session.githubIssuePicker.description.select": "Selecione uma issue para vinculá-la a esta sessão.",
|
||||
"session.githubIssuePicker.description.createSession": "Inicie uma nova sessão com contexto oculto do issue (título/corpo/etiquetas/comentários).",
|
||||
"session.githubIssuePicker.searchPlaceholder": "Buscar por título ou #123, ou colar a URL da issue",
|
||||
"session.githubIssuePicker.searchPlaceholder": "Pesquisar usando a sintaxe de busca de código do GitHub",
|
||||
"session.githubIssuePicker.empty.noActiveProject": "Não há nenhum projeto ativo selecionado.",
|
||||
"session.githubIssuePicker.empty.runtimeUnavailable": "API de runtime de GitHub não disponível.",
|
||||
"session.githubIssuePicker.empty.notConnected": "GitHub não está conectado. Conecte sua conta do GitHub nas configurações.",
|
||||
@@ -1430,7 +1430,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"session.githubPrPicker.toast.loadDetailsFailed": "Não foi possível carregar os detalhes da PR",
|
||||
"session.githubPrPicker.title": "Vincular PR de GitHub",
|
||||
"session.githubPrPicker.description": "Selecione uma PR para anexar contexto de revisão a esta mensagem.",
|
||||
"session.githubPrPicker.searchPlaceholder": "Buscar por título ou #123, ou colar a URL da PR",
|
||||
"session.githubPrPicker.searchPlaceholder": "Pesquisar usando a sintaxe de busca de código do GitHub",
|
||||
"session.githubPrPicker.includeDiffAria": "Incluir diff da PR no contexto adjunto",
|
||||
"session.githubPrPicker.includeDiff": "Incluir diff da PR",
|
||||
"session.githubPrPicker.empty.noActiveProject": "Não há nenhum projeto ativo selecionado.",
|
||||
@@ -1497,8 +1497,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"session.githubIntegration.connect.title": "Conectar a GitHub",
|
||||
"session.githubIntegration.connect.description": "Vincule issues ou PRs para preencher automaticamente os detalhes do worktree",
|
||||
"session.githubIntegration.connect.action": "Conectar a GitHub",
|
||||
"session.githubIntegration.search.issuesPlaceholder": "Buscar issues ou digitar #123...",
|
||||
"session.githubIntegration.search.prsPlaceholder": "Buscar PRs ou digitar #456...",
|
||||
"session.githubIntegration.search.issuesPlaceholder": "Pesquisar usando a sintaxe de busca de código do GitHub",
|
||||
"session.githubIntegration.search.prsPlaceholder": "Pesquisar usando a sintaxe de busca de código do GitHub",
|
||||
"session.githubIntegration.empty.noIssuesFound": "Nenhuma issue encontrada",
|
||||
"session.githubIntegration.empty.noPullRequestsFound": "Nenhuma PR encontrada",
|
||||
"session.githubIntegration.actions.loadMore": "Carregar mais",
|
||||
|
||||
@@ -1403,7 +1403,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"session.githubIssuePicker.title.createSession": "Нова сесія з GitHub issue",
|
||||
"session.githubIssuePicker.description.select": "Виберіть issue, щоб пов’язати її з цією сесією.",
|
||||
"session.githubIssuePicker.description.createSession": "Запускає нову сесію із прихованим контекстом issue: заголовком, текстом, мітками й коментарями.",
|
||||
"session.githubIssuePicker.searchPlaceholder": "Шукайте за назвою або №123, чи вставте URL issue",
|
||||
"session.githubIssuePicker.searchPlaceholder": "Пошук за допомогою синтаксису пошуку коду GitHub",
|
||||
"session.githubIssuePicker.empty.noActiveProject": "Не вибрано жодного активного проєкту.",
|
||||
"session.githubIssuePicker.empty.runtimeUnavailable": "GitHub API недоступний.",
|
||||
"session.githubIssuePicker.empty.notConnected": "GitHub не підключено. Підключіть обліковий запис GitHub у налаштуваннях.",
|
||||
@@ -1430,7 +1430,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"session.githubPrPicker.toast.loadDetailsFailed": "Не вдалося завантажити деталі PR",
|
||||
"session.githubPrPicker.title": "Пов’язати GitHub PR",
|
||||
"session.githubPrPicker.description": "Виберіть PR, щоб додати контекст рев’ю до цього повідомлення.",
|
||||
"session.githubPrPicker.searchPlaceholder": "Шукайте за назвою або #123, чи вставте URL PR",
|
||||
"session.githubPrPicker.searchPlaceholder": "Пошук за допомогою синтаксису пошуку коду GitHub",
|
||||
"session.githubPrPicker.includeDiffAria": "Додати diff PR у вкладений контекст",
|
||||
"session.githubPrPicker.includeDiff": "Додати diff PR",
|
||||
"session.githubPrPicker.empty.noActiveProject": "Не вибрано жодного активного проєкту.",
|
||||
@@ -1497,8 +1497,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"session.githubIntegration.connect.title": "Підключитися до GitHub",
|
||||
"session.githubIntegration.connect.description": "Пов’яжіть issue або PR, щоб автоматично заповнити деталі worktree",
|
||||
"session.githubIntegration.connect.action": "Підключити GitHub",
|
||||
"session.githubIntegration.search.issuesPlaceholder": "Знайдіть issue або введіть #123...",
|
||||
"session.githubIntegration.search.prsPlaceholder": "Знайдіть PR або введіть #456...",
|
||||
"session.githubIntegration.search.issuesPlaceholder": "Пошук за допомогою синтаксису пошуку коду GitHub",
|
||||
"session.githubIntegration.search.prsPlaceholder": "Пошук за допомогою синтаксису пошуку коду GitHub",
|
||||
"session.githubIntegration.empty.noIssuesFound": "Issue не знайдено",
|
||||
"session.githubIntegration.empty.noPullRequestsFound": "PR не знайдено",
|
||||
"session.githubIntegration.actions.loadMore": "Завантажити ще",
|
||||
|
||||
@@ -1403,7 +1403,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'session.githubIssuePicker.title.createSession': '从 GitHub Issue 新建会话',
|
||||
'session.githubIssuePicker.description.select': '选择一个 Issue 关联到当前会话。',
|
||||
'session.githubIssuePicker.description.createSession': '使用隐藏的 Issue 上下文(标题/正文/标签/评论)初始化新会话。',
|
||||
'session.githubIssuePicker.searchPlaceholder': '按标题或 #123 搜索,或粘贴 Issue URL',
|
||||
'session.githubIssuePicker.searchPlaceholder': '使用 GitHub 代码搜索语法进行搜索',
|
||||
'session.githubIssuePicker.empty.noActiveProject': '未选择活动项目。',
|
||||
'session.githubIssuePicker.empty.runtimeUnavailable': 'GitHub 运行时 API 不可用。',
|
||||
'session.githubIssuePicker.empty.notConnected': 'GitHub 未连接。请在设置中连接 GitHub 账号。',
|
||||
@@ -1430,7 +1430,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'session.githubPrPicker.toast.loadDetailsFailed': '加载 Pull Request 详情失败',
|
||||
'session.githubPrPicker.title': '关联 GitHub Pull Request',
|
||||
'session.githubPrPicker.description': '选择一个 Pull Request,将审查上下文附加到此消息。',
|
||||
'session.githubPrPicker.searchPlaceholder': '按标题或 #123 搜索,或粘贴 Pull Request URL',
|
||||
'session.githubPrPicker.searchPlaceholder': '使用 GitHub 代码搜索语法进行搜索',
|
||||
'session.githubPrPicker.includeDiffAria': '在附加上下文中包含 PR 差异',
|
||||
'session.githubPrPicker.includeDiff': '包含 PR 差异',
|
||||
'session.githubPrPicker.empty.noActiveProject': '未选择活动项目。',
|
||||
@@ -1497,8 +1497,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'session.githubIntegration.connect.title': '连接到 GitHub',
|
||||
'session.githubIntegration.connect.description': '关联 Issue 或 Pull Request 以自动填充工作树详情',
|
||||
'session.githubIntegration.connect.action': '连接 GitHub',
|
||||
'session.githubIntegration.search.issuesPlaceholder': '搜索 Issue 或输入 #123...',
|
||||
'session.githubIntegration.search.prsPlaceholder': '搜索 PR 或输入 #456...',
|
||||
'session.githubIntegration.search.issuesPlaceholder': '使用 GitHub 代码搜索语法进行搜索',
|
||||
'session.githubIntegration.search.prsPlaceholder': '使用 GitHub 代码搜索语法进行搜索',
|
||||
'session.githubIntegration.empty.noIssuesFound': '未找到 Issue',
|
||||
'session.githubIntegration.empty.noPullRequestsFound': '未找到 Pull Request',
|
||||
'session.githubIntegration.actions.loadMore': '加载更多',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -191,10 +191,17 @@ export const createWebGitHubAPI = ({ urls }: WebGitHubAPIOptions): GitHubAPI =>
|
||||
return body.branches ?? [];
|
||||
},
|
||||
|
||||
async prsList(directory: string, options?: { page?: number }): Promise<GitHubPullRequestsListResult> {
|
||||
async prsList(directory: string, options?: { page?: number; query?: string }): Promise<GitHubPullRequestsListResult> {
|
||||
const page = options?.page ?? 1;
|
||||
const params = new URLSearchParams({
|
||||
directory,
|
||||
page: String(page),
|
||||
});
|
||||
if (options?.query) {
|
||||
params.set('query', options.query);
|
||||
}
|
||||
const response = await runtimeFetch(
|
||||
`/api/github/pulls/list?directory=${encodeURIComponent(directory)}&page=${encodeURIComponent(String(page))}`,
|
||||
`/api/github/pulls/list?${params.toString()}`,
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const body = await jsonOrNull<GitHubPullRequestsListResult & { error?: string }>(response);
|
||||
@@ -228,10 +235,17 @@ export const createWebGitHubAPI = ({ urls }: WebGitHubAPIOptions): GitHubAPI =>
|
||||
return body;
|
||||
},
|
||||
|
||||
async issuesList(directory: string, options?: { page?: number }): Promise<GitHubIssuesListResult> {
|
||||
async issuesList(directory: string, options?: { page?: number; query?: string }): Promise<GitHubIssuesListResult> {
|
||||
const page = options?.page ?? 1;
|
||||
const params = new URLSearchParams({
|
||||
directory,
|
||||
page: String(page),
|
||||
});
|
||||
if (options?.query) {
|
||||
params.set('query', options.query);
|
||||
}
|
||||
const response = await runtimeFetch(
|
||||
`/api/github/issues/list?directory=${encodeURIComponent(directory)}&page=${encodeURIComponent(String(page))}`,
|
||||
`/api/github/issues/list?${params.toString()}`,
|
||||
{ method: 'GET', headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const payload = await jsonOrNull<GitHubIssuesListResult & { error?: string }>(response);
|
||||
|
||||
Reference in New Issue
Block a user