From 21253d7fc211757dfd4d0fbcfd08a5d442bc5f39 Mon Sep 17 00:00:00 2001 From: Islam Nofl Date: Wed, 29 Apr 2026 12:03:39 +0300 Subject: [PATCH] feat: fork-aware issue/PR listing & OpenCode startup loading indicator (#1061) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add design spec: OpenCode readiness loading indicator * Add implementation plan: OpenCode readiness loading indicator * feat: add useOpenCodeReadiness hook * feat: add i18n keys for common.loading * feat: add loading state to ModelSelector * feat: add loading state to AgentSelector * feat: add loading state to ModelControls chat selectors * update package-lock * feat(github): add shared fork detection utility * feat(github): make issue listing fork-aware * feat(github): make PR listing fork-aware * feat(types): add sourceRepo to issue/PR summary types * feat(ui): add source badges to GitHub integration dialog * feat(ui): add source badges to issue/PR picker dialogs * feat(github): pass headRemote in PR creation for fork support * feat(ui): add source→target label in PR tab for fork workflows * fix(github): allow PR section on base branch when upstream remote exists * fix(github): show PR section on any branch including main for fork→upstream PRs * fix(github): allow PullRequestSection to render on base branch when upstream remote exists * feat(github): auto-detect upstream repo for fork→upstream PR creation - Add GET /api/github/repo/upstream endpoint to discover fork's upstream - Fix PullRequestSection canShow to allow PR creation on base branch when repo is a fork - Add virtual upstream target in remote dropdown (no explicit upstream remote needed) - Add targetRepo parameter to /api/github/pr/create for direct upstream targeting - Add repoUpstream() API client method and GitHubRepoUpstreamResult type * feat(github): auto-detect upstream repo for fork→upstream PR creation - Add GET /api/github/repo/upstream endpoint to discover fork's upstream - Fix PullRequestSection canShow to allow PR creation on base branch when repo is a fork - Add virtual upstream target in remote dropdown (no explicit upstream remote needed) - Add targetRepo parameter to /api/github/pr/create for direct upstream targeting - Add repoUpstream() API client method and GitHubRepoUpstreamResult type * fix: complete fork→upstream PR workflow - Server: return defaultBranch from /api/github/repo/upstream endpoint - Server: fix cross-repo head ref construction (compare repos, not remote names) - Server: filterActiveRemoteBranches checks all remotes, not just origin - UI: set targetBaseBranch to upstream's default branch when using detected upstream - UI: include all remote branches in base branch dropdown when using detected upstream - UI: skip base===head check for cross-repo PRs (same branch name on different repos is valid) - Types: add defaultBranch to GitHubRepoUpstreamResult * chore: delete superpowers folder * feat: add (local)/(remote) labels to PR branch display and adapt Repository button to selected remote * feat: Repository button adapts to selected remote (upstream vs origin) * fix: complete fork→upstream PR feature gaps Server: - Extend /api/github/repo/upstream to return defaultBranchSha and remoteName - Reuse headRepo result instead of redundant resolveGitHubRepoFromDirectory call - Return clear error when headRepo is null (invalid GitHub URL) UI: - Add upstream's default branch to availableBaseBranches when using detected upstream - Use upstream's default branch SHA in git log for generate description (fixes 'No commits found in range main...main') - Show qualified names (owner/repo · branch) in base branch dropdown when using detected upstream Types: - Add defaultBranchSha and remoteName to GitHubRepoUpstreamResult * fix: move detectedUpstream state before availableBaseBranches to fix temporal dead zone * fix: fetch upstream branches from GitHub API for base branch dropdown - Add GET /api/github/repo/branches endpoint to fetch branches via Octokit - Add repoBranches() to GitHub API client and interface - Fetch upstream branches on detection and store in upstreamBranches state - Include upstreamBranches in availableBaseBranches when using detected upstream - Re-add availableBaseBranches memo and auto-correction effect that were lost - Remove unnecessary qualified names from dropdown (upstream is already selected) * fix: restore prStatusKey and statusEntry declarations lost during refactor * fix: cleanly re-apply all fork→upstream PR UI changes Restored PullRequestSection.tsx from clean base and re-applied: - Expand detectedUpstream type with defaultBranch, defaultBranchSha, remoteName - Add upstreamBranches state and fetch on upstream detection - Include upstream branches in availableBaseBranches when using detected upstream - Use upstream default branch SHA in generate description (fixes 'No commits found') - Adapt Repository button URL to selected remote - Add (local)/(remote)/(upstream) labels to branch display * fix: move detectedUpstream/upstreamBranches before availableBaseBranches to fix TDZ * style: add pill badge styling to upstream repo source labels * fix: don't cache error PR status responses, allow force-bypass of server cache * fix: resolve PR status cache bugs, stale directory fallback, and upstream re-detection * fix: keep collapse button visible when scrolling long user messages - Collapse button now sticks to top of scrollable user message content instead of scrolling away * fix: checkbox focus ring blends into sidebar background * fix: polish fork PR follow-ups * fix: remove user message collapse artifact * fix: tighten fork PR internals * fix: check all remotes for fork PR status * fix: recover sidebar PR status misses --------- Signed-off-by: Islam Nofl Co-authored-by: Bohdan Triapitsyn --- .../ui/src/components/chat/ModelControls.tsx | 212 ++++++---- .../chat/message/parts/UserTextPart.tsx | 2 +- .../sections/agents/ModelSelector.tsx | 56 ++- .../sections/commands/AgentSelector.tsx | 35 +- .../session/GitHubIntegrationDialog.tsx | 18 +- .../session/GitHubIssuePickerDialog.tsx | 37 +- .../session/GitHubPrPickerDialog.tsx | 14 +- .../src/components/session/SessionSidebar.tsx | 21 +- packages/ui/src/components/ui/checkbox.tsx | 14 +- packages/ui/src/components/views/GitView.tsx | 2 +- .../views/git/PullRequestSection.tsx | 178 +++++++- packages/ui/src/hooks/useOpenCodeReadiness.ts | 15 + packages/ui/src/lib/api/types.ts | 25 +- packages/ui/src/lib/i18n/messages/en.ts | 4 + packages/ui/src/lib/i18n/messages/es.ts | 4 + packages/ui/src/lib/i18n/messages/ko.ts | 4 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 4 + packages/ui/src/lib/i18n/messages/uk.ts | 4 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 4 + .../ui/src/stores/useGitHubPrStatusStore.ts | 2 +- packages/vscode/src/bridge.ts | 4 +- packages/vscode/webview/api/github.ts | 17 +- packages/web/server/lib/git/service.js | 31 +- packages/web/server/lib/github/pr-status.js | 80 ++-- .../server/lib/github/repo/fork-detection.js | 102 +++++ packages/web/server/lib/github/routes.js | 387 ++++++++++++++---- packages/web/src/api/github.ts | 62 ++- 27 files changed, 1042 insertions(+), 296 deletions(-) create mode 100644 packages/ui/src/hooks/useOpenCodeReadiness.ts create mode 100644 packages/web/server/lib/github/repo/fork-detection.js diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index d1670fa0..439c977a 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -25,6 +25,7 @@ import { RiFileMusicLine, RiFilePdfLine, RiFileVideoLine, + RiLoader4Line, RiPencilAiLine, RiQuestionLine, RiSearchLine, @@ -67,6 +68,7 @@ import { useModelLists } from '@/hooks/useModelLists'; import { useIsTextTruncated } from '@/hooks/useIsTextTruncated'; import { formatEffortLabel, getCycledPrimaryAgentName, type MobileControlsPanel } from './mobileControlsUtils'; import { useI18n } from '@/lib/i18n'; +import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type IconComponent = ComponentType; @@ -341,6 +343,8 @@ export const ModelControls: React.FC = ({ onMobilePanelChange, }) => { const { t } = useI18n(); + const { isReady, isUnavailable } = useOpenCodeReadiness(); + const readinessLabel = isUnavailable ? t('common.unavailable') : t('common.loading'); const providers = useConfigStore((state) => state.providers); const currentProviderId = useConfigStore((state) => state.currentProviderId); const currentModelId = useConfigStore((state) => state.currentModelId); @@ -2652,7 +2656,7 @@ export const ModelControls: React.FC = ({ return ( {!isCompact ? ( - +
= ({ buttonHeight )} > - {currentProviderId ? ( + {!isReady ? ( + <> + + + {readinessLabel} + + + ) : currentProviderId ? ( <> = ({ ) : ( )} + {isReady && ( = ({ {currentModelDisplayName} + )}
@@ -2891,35 +2908,47 @@ export const ModelControls: React.FC = ({ ) : ( )} {renderModelTooltipContent()} @@ -3056,7 +3085,7 @@ export const ModelControls: React.FC = ({ }; const renderVariantSelector = () => { - if (!hasVariants) { + if (!isReady || !hasVariants) { return null; } @@ -3154,32 +3183,54 @@ export const ModelControls: React.FC = ({ return (
- +
- - - {getAgentDisplayName()} - + {!isReady ? ( + <> + + + {readinessLabel} + + + ) : ( + <> + + + {getAgentDisplayName()} + + + )}
@@ -3257,35 +3308,58 @@ export const ModelControls: React.FC = ({ return ( ); }; diff --git a/packages/ui/src/components/chat/message/parts/UserTextPart.tsx b/packages/ui/src/components/chat/message/parts/UserTextPart.tsx index 6347db9e..41f48956 100644 --- a/packages/ui/src/components/chat/message/parts/UserTextPart.tsx +++ b/packages/ui/src/components/chat/message/parts/UserTextPart.tsx @@ -146,7 +146,7 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti ) : ( - +
- {providerId ? ( + {!isReady ? ( <> - - + + + {isUnavailable ? t('common.unavailable') : t('common.loading')} + ) : ( - + <> + {providerId ? ( + <> + + + + ) : ( + + )} + + {providerId && modelId ? `${providerId}/${modelId}` : (placeholder || t('settings.agents.modelSelector.notSelected'))} + + )} - - {providerId && modelId ? `${providerId}/${modelId}` : (placeholder || t('settings.agents.modelSelector.notSelected'))} -
diff --git a/packages/ui/src/components/sections/commands/AgentSelector.tsx b/packages/ui/src/components/sections/commands/AgentSelector.tsx index 4b15a2ec..8e84a005 100644 --- a/packages/ui/src/components/sections/commands/AgentSelector.tsx +++ b/packages/ui/src/components/sections/commands/AgentSelector.tsx @@ -10,10 +10,11 @@ import { useAgentsStore, filterVisibleAgents } from '@/stores/useAgentsStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { useDeviceInfo } from '@/lib/device'; -import { RiArrowDownSLine, RiRobot2Line } from '@remixicon/react'; +import { RiArrowDownSLine, RiLoader4Line, RiRobot2Line } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { useI18n } from '@/lib/i18n'; +import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness'; interface AgentSelectorProps { agentName: string; @@ -29,6 +30,7 @@ export const AgentSelector: React.FC = ({ filter, }) => { const { t } = useI18n(); + const { isReady, isUnavailable } = useOpenCodeReadiness(); const configAgents = useConfigStore((state) => state.agents); const agentsStoreAgents = useAgentsStore((state) => state.agents); const loadAgentsStore = useAgentsStore((state) => state.loadAgents); @@ -125,20 +127,41 @@ export const AgentSelector: React.FC = ({ {isActuallyMobile ? ( + ) : !isReady ? ( +
+ + + {isUnavailable ? t('common.unavailable') : t('common.loading')} + +
) : ( diff --git a/packages/ui/src/components/session/GitHubIntegrationDialog.tsx b/packages/ui/src/components/session/GitHubIntegrationDialog.tsx index b709ae09..0deacae2 100644 --- a/packages/ui/src/components/session/GitHubIntegrationDialog.tsx +++ b/packages/ui/src/components/session/GitHubIntegrationDialog.tsx @@ -347,7 +347,7 @@ export function GitHubIntegrationDialog({ {filteredIssues.length > 0 ? ( filteredIssues.map(issue => ( )) @@ -398,7 +405,7 @@ export function GitHubIntegrationDialog({ return ( + +

{t('gitView.pr.actions.refresh')}

+
{checks ? ( {checks.total > 0 ? `${checks.success}/${checks.total} checks` : `${checks.state} checks`} ) : null} - {hasMultipleRemotes ? ( + {trackingBranch && selectedRemote && trackingBranch.split('/')[0] !== selectedRemote.name ? ( + + {trackingBranch.split('/')[0]} → {selectedRemote.name} + + ) : null} + {hasMultipleRemotes || detectedUpstream ? ( @@ -1349,12 +1472,15 @@ export const PullRequestSection: React.FC<{ {remotes.map((remote) => ( handleRemoteChange(remote)} + onSelect={() => { + setUseDetectedUpstream(false); + handleRemoteChange(remote); + }} >
{remote.name} - {remote.name === selectedRemote?.name && ( + {!useDetectedUpstream && remote.name === selectedRemote?.name && ( )} @@ -1364,6 +1490,24 @@ export const PullRequestSection: React.FC<{
))} + {detectedUpstream ? ( + setUseDetectedUpstream(true)} + > +
+ + upstream · {detectedUpstream.owner}/{detectedUpstream.repo} + {useDetectedUpstream && ( + + )} + + + {detectedUpstream.url} + +
+
+ ) : null}
) : null} @@ -1647,7 +1791,7 @@ export const PullRequestSection: React.FC<{
{t('gitView.pr.createTitle')}
- {branch} → {targetBaseBranch} + {branch} (local) → {targetBaseBranch} ({useDetectedUpstream && detectedUpstream ? 'upstream' : 'remote'})
{repoUrl ? ( @@ -1822,7 +1966,7 @@ export const PullRequestSection: React.FC<{ size="sm" className="min-w-[7.5rem] justify-center gap-2" onClick={createPr} - disabled={isCreating || !isConnected || !targetBaseBranch.trim() || targetBaseBranch.trim() === branch} + disabled={isCreating || !isConnected || !targetBaseBranch.trim() || (!useDetectedUpstream && targetBaseBranch.trim() === branch)} > {isCreating ? : } diff --git a/packages/ui/src/hooks/useOpenCodeReadiness.ts b/packages/ui/src/hooks/useOpenCodeReadiness.ts new file mode 100644 index 00000000..cda696b2 --- /dev/null +++ b/packages/ui/src/hooks/useOpenCodeReadiness.ts @@ -0,0 +1,15 @@ +import { useConfigStore } from '@/stores/useConfigStore'; + +export function useOpenCodeReadiness() { + const isInitialized = useConfigStore((s) => s.isInitialized); + const connectionPhase = useConfigStore((s) => s.connectionPhase); + const lastDisconnectReason = useConfigStore((s) => s.lastDisconnectReason); + const isUnavailable = !isInitialized && lastDisconnectReason === 'init_error'; + + return { + isReady: isInitialized, + isLoading: !isInitialized && !isUnavailable, + isUnavailable, + connectionPhase, + }; +} diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 2a6b6e96..33fce769 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -779,6 +779,7 @@ export type GitHubPullRequestSummary = GitHubPullRequest & { updatedAt?: string; headLabel?: string; headRepo?: GitHubPullRequestHeadRepo | null; + sourceRepo?: (GitHubRepoSelector & { source: string }) | null; }; export type GitHubPullRequestFile = { @@ -844,6 +845,8 @@ export type GitHubPullRequestCreateInput = { remote?: string; /** Remote where the head branch lives (source repo, e.g., 'origin' for forks) */ headRemote?: string; + /** Explicit target repo (alternative to remote, for auto-detected upstream) */ + targetRepo?: { owner: string; repo: string }; }; export type GitHubPullRequestUpdateInput = { @@ -878,6 +881,11 @@ export type GitHubIssueLabel = { color?: string; }; +export type GitHubRepoSelector = { + owner: string; + repo: string; +}; + export type GitHubIssueSummary = { number: number; title: string; @@ -885,6 +893,7 @@ export type GitHubIssueSummary = { state: 'open' | 'closed'; author?: GitHubUserSummary | null; labels?: GitHubIssueLabel[]; + sourceRepo?: (GitHubRepoSelector & { source: string }) | null; }; export type GitHubIssue = GitHubIssueSummary & { @@ -911,6 +920,12 @@ export type GitHubIssuesListResult = { hasMore?: boolean; }; +export type GitHubRepoUpstreamResult = { + connected: boolean; + isFork: boolean; + upstream: { owner: string; repo: string; url: string; defaultBranch: string; defaultBranchSha: string | null; remoteName: string | null } | null; +}; + export type GitHubIssueGetResult = { connected: boolean; repo?: GitHubRepoRef | null; @@ -959,7 +974,7 @@ export interface GitHubAPI { authActivate(accountId: string): Promise; me?(): Promise; - prStatus(directory: string, branch: string, remote?: string): Promise; + prStatus(directory: string, branch: string, remote?: string, options?: { force?: boolean }): Promise; prCreate(payload: GitHubPullRequestCreateInput): Promise; prUpdate(payload: GitHubPullRequestUpdateInput): Promise; prMerge(payload: GitHubPullRequestMergeInput): Promise; @@ -969,12 +984,14 @@ export interface GitHubAPI { prContext( directory: string, number: number, - options?: { includeDiff?: boolean; includeCheckDetails?: boolean } + options?: { includeDiff?: boolean; includeCheckDetails?: boolean; sourceRepo?: GitHubRepoSelector | null } ): Promise; issuesList(directory: string, options?: { page?: number }): Promise; - issueGet(directory: string, number: number): Promise; - issueComments(directory: string, number: number): Promise; + issueGet(directory: string, number: number, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise; + issueComments(directory: string, number: number, options?: { sourceRepo?: GitHubRepoSelector | null }): Promise; + repoUpstream(directory: string): Promise; + repoBranches(owner: string, repo: string): Promise; } export interface RuntimeAPIs { diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 05753504..84a2d5ce 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2,6 +2,8 @@ import { settingsDict } from './en.settings'; export const dict = { ...settingsDict, + 'common.loading': 'Loading...', + 'common.unavailable': 'Unavailable', 'common.language.english': 'English', 'common.language.simplifiedChinese': 'Chinese (Simplified)', 'common.language.ukrainian': 'Ukrainian', @@ -566,6 +568,8 @@ export const dict = { 'gitView.pr.actions.shareComments': 'Share comments', 'gitView.pr.actions.shareCommentsAria': 'Send pull request comments to agent', 'gitView.pr.actions.toggleDraftAria': 'Toggle draft state', + 'gitView.pr.actions.refresh': 'Refresh PR status', + 'gitView.pr.actions.refreshAria': 'Refresh pull request status', 'gitView.pr.additionalContext.added': 'Added', 'gitView.pr.additionalContext.hint': 'Add extra context for better review quality.', 'gitView.pr.additionalContext.optional': 'Optional', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index f1972182..02634cf4 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -3,6 +3,8 @@ import { settingsDict } from './es.settings'; export const dict: Record = { ...settingsDict, + "common.loading": "Cargando...", + "common.unavailable": "No disponible", "common.language.english": "Inglés", "common.language.simplifiedChinese": "Chino (simplificado)", "common.language.ukrainian": "Ucraniano", @@ -567,6 +569,8 @@ export const dict: Record = { "gitView.pr.actions.shareComments": "Compartir comentarios", "gitView.pr.actions.shareCommentsAria": "Enviar comentarios de la PR al agente", "gitView.pr.actions.toggleDraftAria": "Alternar estado de borrador", + "gitView.pr.actions.refresh": "Actualizar estado de la PR", + "gitView.pr.actions.refreshAria": "Actualizar estado del pull request", "gitView.pr.additionalContext.added": "Añadido", "gitView.pr.additionalContext.hint": "Añade contexto adicional para una revisión más efectiva.", "gitView.pr.additionalContext.optional": "Opcional", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 76572a74..e9c23877 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -3,6 +3,8 @@ import { settingsDict } from './ko.settings'; export const dict: Record = { ...settingsDict, + 'common.loading': '로딩 중...', + 'common.unavailable': '사용할 수 없음', 'common.language.english': '영어', 'common.language.simplifiedChinese': '중국어(간체)', 'common.language.ukrainian': '우크라이나어', @@ -567,6 +569,8 @@ export const dict: Record = { 'gitView.pr.actions.shareComments': 'Share 댓글', 'gitView.pr.actions.shareCommentsAria': '보내기 PR 댓글로 에이전트', 'gitView.pr.actions.toggleDraftAria': '토글 draft state', + 'gitView.pr.actions.refresh': 'PR 상태 새로고침', + 'gitView.pr.actions.refreshAria': '풀 리퀘스트 상태 새로고침', 'gitView.pr.additionalContext.added': '추가됨', 'gitView.pr.additionalContext.hint': '더 나은 리뷰를 위해 추가 컨텍스트를 넣으세요.', 'gitView.pr.additionalContext.optional': '선택 사항', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 2f01997f..9637835d 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -3,6 +3,8 @@ import { settingsDict } from './pt-BR.settings'; export const dict: Record = { ...settingsDict, + "common.loading": "Carregando...", + "common.unavailable": "Indisponível", "common.language.english": "Inglês", "common.language.simplifiedChinese": "Chinês (simplificado)", "common.language.ukrainian": "Ucraniano", @@ -567,6 +569,8 @@ export const dict: Record = { "gitView.pr.actions.shareComments": "Compartilhar comentários", "gitView.pr.actions.shareCommentsAria": "Enviar comentários da PR ao agente", "gitView.pr.actions.toggleDraftAria": "Alternar status de rascunho", + "gitView.pr.actions.refresh": "Atualizar status da PR", + "gitView.pr.actions.refreshAria": "Atualizar status do pull request", "gitView.pr.additionalContext.added": "Adicionado", "gitView.pr.additionalContext.hint": "Adicione contexto adicional para melhorar a qualidade da revisão.", "gitView.pr.additionalContext.optional": "Opcional", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 6c4074a8..c3a1bc7b 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -3,6 +3,8 @@ import { settingsDict } from './uk.settings'; export const dict: Record = { ...settingsDict, + "common.loading": "Завантаження...", + "common.unavailable": "Недоступно", "common.language.english": "англійська", "common.language.simplifiedChinese": "Китайська (спрощена)", "common.language.ukrainian": "Українська", @@ -567,6 +569,8 @@ export const dict: Record = { "gitView.pr.actions.shareComments": "Поділитися коментарями", "gitView.pr.actions.shareCommentsAria": "Надсилати агенту коментарі PR", "gitView.pr.actions.toggleDraftAria": "Перемкнути стан чернетки", + "gitView.pr.actions.refresh": "Оновити статус PR", + "gitView.pr.actions.refreshAria": "Оновити статус pull request", "gitView.pr.additionalContext.added": "Додано", "gitView.pr.additionalContext.hint": "Додати додатковий контекст для кращої якості огляду.", "gitView.pr.additionalContext.optional": "Додатково", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index b48d1c72..29dc13dc 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -3,6 +3,8 @@ import { settingsDict } from './zh-CN.settings'; export const dict: Record = { ...settingsDict, + 'common.loading': '加载中...', + 'common.unavailable': '不可用', 'common.language.english': 'English', 'common.language.simplifiedChinese': '简体中文', 'common.language.ukrainian': '乌克兰语', @@ -567,6 +569,8 @@ export const dict: Record = { 'gitView.pr.actions.shareComments': '分享评论', 'gitView.pr.actions.shareCommentsAria': '将拉取请求评论发送给智能体', 'gitView.pr.actions.toggleDraftAria': '切换草稿状态', + 'gitView.pr.actions.refresh': '刷新 PR 状态', + 'gitView.pr.actions.refreshAria': '刷新拉取请求状态', 'gitView.pr.additionalContext.added': '已添加', 'gitView.pr.additionalContext.hint': '添加额外上下文可提升审查质量。', 'gitView.pr.additionalContext.optional': '可选', diff --git a/packages/ui/src/stores/useGitHubPrStatusStore.ts b/packages/ui/src/stores/useGitHubPrStatusStore.ts index 2113bbf7..61f7cd69 100644 --- a/packages/ui/src/stores/useGitHubPrStatusStore.ts +++ b/packages/ui/src/stores/useGitHubPrStatusStore.ts @@ -486,7 +486,7 @@ export const useGitHubPrStatusStore = create()( activeRequestCount: prev.activeRequestCount + 1, totalRequestCount: prev.totalRequestCount + 1, })); - const next = await params.github.prStatus(params.directory, params.branch, params.remoteName ?? undefined); + const next = await params.github.prStatus(params.directory, params.branch, params.remoteName ?? undefined, { force: options?.force }); set((prev) => { const nextEntries = { ...prev.entries }; signatureKeys.forEach((signatureKey) => { diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index 36dfb85c..7a9906b0 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -152,7 +152,9 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo case 'api:github/issues:get': case 'api:github/issues:comments': case 'api:github/pulls:list': - case 'api:github/pulls:context': { + case 'api:github/pulls:context': + case 'api:github/repo:upstream': + case 'api:github/repo:branches': { return { id, type, success: false, error: GITHUB_BACKEND_DISABLED_ERROR }; } diff --git a/packages/vscode/webview/api/github.ts b/packages/vscode/webview/api/github.ts index be0f83f1..9cfbce5f 100644 --- a/packages/vscode/webview/api/github.ts +++ b/packages/vscode/webview/api/github.ts @@ -16,6 +16,7 @@ import type { GitHubPullRequestStatus, GitHubDeviceFlowComplete, GitHubDeviceFlowStart, + GitHubRepoUpstreamResult, GitHubUserSummary, } from '@openchamber/ui/lib/api/types'; @@ -44,18 +45,24 @@ export const createVSCodeGitHubAPI = (): GitHubAPI => ({ issuesList: async (directory: string, options?: { page?: number }) => sendBridgeMessage('api:github/issues:list', { directory, page: options?.page ?? 1 }), - issueGet: async (directory: string, number: number) => - sendBridgeMessage('api:github/issues:get', { directory, number }), - issueComments: async (directory: string, number: number) => - sendBridgeMessage('api:github/issues:comments', { directory, number }), + issueGet: async (directory: string, number: number, options?: { sourceRepo?: { owner: string; repo: string } | null }) => + sendBridgeMessage('api:github/issues:get', { directory, number, sourceRepo: options?.sourceRepo ?? null }), + issueComments: async (directory: string, number: number, options?: { sourceRepo?: { owner: string; repo: string } | null }) => + sendBridgeMessage('api:github/issues:comments', { directory, number, sourceRepo: options?.sourceRepo ?? null }), prsList: async (directory: string, options?: { page?: number }) => sendBridgeMessage('api:github/pulls:list', { directory, page: options?.page ?? 1 }), - prContext: async (directory: string, number: number, options?: { includeDiff?: boolean; includeCheckDetails?: boolean }) => + prContext: async (directory: string, number: number, options?: { includeDiff?: boolean; includeCheckDetails?: boolean; sourceRepo?: { owner: string; repo: string } | null }) => sendBridgeMessage('api:github/pulls:context', { directory, number, includeDiff: Boolean(options?.includeDiff), includeCheckDetails: Boolean(options?.includeCheckDetails), + sourceRepo: options?.sourceRepo ?? null, }), + + repoUpstream: async (directory: string) => + sendBridgeMessage('api:github/repo:upstream', { directory }), + repoBranches: async (owner: string, repo: string) => + sendBridgeMessage('api:github/repo:branches', { owner, repo }), }); diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 5febe6e6..aa15b56b 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -2084,25 +2084,32 @@ export async function getBranches(directory) { async function filterActiveRemoteBranches(git, remoteBranches) { try { + const remotes = await git.getRemotes(); + const branchesByRemote = new Map(); - const lsRemoteResult = await git.raw(['ls-remote', '--heads', 'origin']); - const actualRemoteBranches = new Set(); - - const lines = lsRemoteResult.trim().split('\n'); - for (const line of lines) { - if (line.includes('\trefs/heads/')) { - const branchName = line.split('\t')[1].replace('refs/heads/', ''); - actualRemoteBranches.add(branchName); + await Promise.all(remotes.map(async (remote) => { + try { + const lsRemoteResult = await git.raw(['ls-remote', '--heads', remote.name]); + const actualRemoteBranches = new Set(); + const lines = lsRemoteResult.trim().split('\n'); + for (const line of lines) { + if (line.includes('\trefs/heads/')) { + const branchName = line.split('\t')[1].replace('refs/heads/', ''); + actualRemoteBranches.add(branchName); + } + } + branchesByRemote.set(remote.name, actualRemoteBranches); + } catch { + // Skip remotes that fail (e.g., unreachable) } - } + })); return remoteBranches.filter(remoteBranch => { - const match = remoteBranch.match(/^remotes\/[^\/]+\/(.+)$/); if (!match) return false; - + const remoteName = remoteBranch.split('/')[1]; const branchName = match[1]; - return actualRemoteBranches.has(branchName); + return branchesByRemote.get(remoteName)?.has(branchName) ?? false; }); } catch (error) { console.warn('Failed to filter active remote branches, returning all:', error.message); diff --git a/packages/web/server/lib/github/pr-status.js b/packages/web/server/lib/github/pr-status.js index 041058e3..6374827d 100644 --- a/packages/web/server/lib/github/pr-status.js +++ b/packages/web/server/lib/github/pr-status.js @@ -27,6 +27,18 @@ const parseTrackingRemoteName = (trackingBranch) => { return normalized.slice(0, slashIndex).trim(); }; +const parseTrackingBranchName = (trackingBranch) => { + const normalized = normalizeText(trackingBranch); + if (!normalized) { + return ''; + } + const slashIndex = normalized.indexOf('/'); + if (slashIndex <= 0 || slashIndex >= normalized.length - 1) { + return ''; + } + return normalized.slice(slashIndex + 1).trim(); +}; + const pushUnique = (collection, value, keyFn = normalizeLower) => { const normalizedValue = normalizeText(value); if (!normalizedValue) { @@ -421,13 +433,17 @@ export async function resolveGitHubPrStatus({ octokit, directory, branch, remote ]); const trackingRemoteName = parseTrackingRemoteName(status?.tracking); + const trackingBranchName = parseTrackingBranchName(status?.tracking); + const branchCandidates = []; + pushUnique(branchCandidates, normalizedBranch); + pushUnique(branchCandidates, trackingBranchName); const rankedRemoteNames = rankRemoteNames( Array.isArray(remotes) ? remotes.map((remote) => remote?.name).filter(Boolean) : [], normalizedRemoteName, trackingRemoteName, ); - const resolvedRemoteTargets = await resolveRemoteCandidates(directory, rankedRemoteNames.slice(0, 3)); + const resolvedRemoteTargets = await resolveRemoteCandidates(directory, rankedRemoteNames); const resolvedTargets = await expandRepoNetwork( octokit, resolvedRemoteTargets.map((target, index) => ({ ...target, priority: index })), @@ -454,38 +470,44 @@ export async function resolveGitHubPrStatus({ octokit, directory, branch, remote fallbackRemoteName = target.remoteName; fallbackDefaultBranch = defaultBranch; } - if (defaultBranch && defaultBranch === normalizedBranch) { - continue; - } - const pr = await findFirstMatchingPr({ - octokit, - target, - branch: normalizedBranch, - sourceCandidates, - }); - if (pr) { - return { - repo: target.repo, - pr, - defaultBranch, - resolvedRemoteName: target.remoteName, - }; + const hasCrossRepoSource = sourceCandidates.some((candidate) => normalizeRepoKey(candidate.repo?.owner, candidate.repo?.repo) !== normalizeRepoKey(target.repo?.owner, target.repo?.repo)); + for (const candidateBranch of branchCandidates) { + if (defaultBranch && defaultBranch === candidateBranch && !hasCrossRepoSource) { + continue; + } + + const pr = await findFirstMatchingPr({ + octokit, + target, + branch: candidateBranch, + sourceCandidates, + }); + if (pr) { + return { + repo: target.repo, + pr, + defaultBranch, + resolvedRemoteName: target.remoteName, + }; + } } } - const fallbackSearch = await searchFallbackPr({ - octokit, - branch: normalizedBranch, - repoNames: resolvedTargets.map((target) => target.repo.repo), - }); - if (fallbackSearch) { - return { - repo: fallbackSearch.repo, - pr: fallbackSearch.pr, - defaultBranch: await getRepoDefaultBranch(octokit, fallbackSearch.repo), - resolvedRemoteName: null, - }; + for (const candidateBranch of branchCandidates) { + const fallbackSearch = await searchFallbackPr({ + octokit, + branch: candidateBranch, + repoNames: resolvedTargets.map((target) => target.repo.repo), + }); + if (fallbackSearch) { + return { + repo: fallbackSearch.repo, + pr: fallbackSearch.pr, + defaultBranch: await getRepoDefaultBranch(octokit, fallbackSearch.repo), + resolvedRemoteName: null, + }; + } } return { diff --git a/packages/web/server/lib/github/repo/fork-detection.js b/packages/web/server/lib/github/repo/fork-detection.js new file mode 100644 index 00000000..cd24d547 --- /dev/null +++ b/packages/web/server/lib/github/repo/fork-detection.js @@ -0,0 +1,102 @@ +import { resolveGitHubRepoFromDirectory } from './index.js'; + +const REPO_METADATA_TTL_MS = 5 * 60_000; +const REPO_METADATA_CACHE_MAX_ENTRIES = 200; +const repoMetadataCache = new Map(); + +const setRepoMetadataCache = (repoKey, data) => { + if (repoMetadataCache.size >= REPO_METADATA_CACHE_MAX_ENTRIES && !repoMetadataCache.has(repoKey)) { + const oldest = repoMetadataCache.entries().next().value; + if (oldest) { + repoMetadataCache.delete(oldest[0]); + } + } + repoMetadataCache.set(repoKey, { data, fetchedAt: Date.now() }); +}; + +const normalizeRepoKey = (owner, repo) => { + const o = typeof owner === 'string' ? owner.trim().toLowerCase() : ''; + const r = typeof repo === 'string' ? repo.trim().toLowerCase() : ''; + if (!o || !r) return ''; + return `${o}/${r}`; +}; + +const getRepoMetadata = async (octokit, repo) => { + const repoKey = normalizeRepoKey(repo?.owner, repo?.repo); + if (!repoKey) return null; + + const cached = repoMetadataCache.get(repoKey); + if (cached && Date.now() - cached.fetchedAt < REPO_METADATA_TTL_MS) { + return cached.data; + } + + try { + const response = await octokit.rest.repos.get({ + owner: repo.owner, + repo: repo.repo, + }); + const data = response?.data ?? null; + setRepoMetadataCache(repoKey, data); + return data; + } catch (error) { + if (error?.status === 403 || error?.status === 404) { + setRepoMetadataCache(repoKey, null); + return null; + } + throw error; + } +}; + +/** + * Resolve the repo network for a directory. If the origin repo is a fork, + * includes the parent/source (upstream) repo in the result. + * + * @param {import('@octokit/rest').Octokit} octokit + * @param {string} directory + * @param {string} [remoteName='origin'] + * @returns {Promise | null>} + * Array of repos to query (origin first, then upstream), or null if not a fork. + */ +export async function resolveRepoNetwork(octokit, directory, remoteName = 'origin') { + const { repo } = await resolveGitHubRepoFromDirectory(directory, remoteName).catch(() => ({ repo: null })); + if (!repo) return null; + + const metadata = await getRepoMetadata(octokit, repo); + if (!metadata) return [{ ...repo, source: 'origin' }]; + + const result = [{ ...repo, source: 'origin' }]; + const seenKeys = new Set([normalizeRepoKey(repo.owner, repo.repo)]); + + const parent = metadata?.parent; + if (parent?.owner?.login && parent?.name) { + const key = normalizeRepoKey(parent.owner.login, parent.name); + if (!seenKeys.has(key)) { + seenKeys.add(key); + result.push({ + owner: parent.owner.login, + repo: parent.name, + url: parent.html_url || `https://github.com/${parent.owner.login}/${parent.name}`, + source: 'upstream', + }); + } + } + + const source = metadata?.source; + if (source?.owner?.login && source?.name) { + const key = normalizeRepoKey(source.owner.login, source.name); + if (!seenKeys.has(key)) { + seenKeys.add(key); + result.push({ + owner: source.owner.login, + repo: source.name, + url: source.html_url || `https://github.com/${source.owner.login}/${source.name}`, + source: 'upstream', + }); + } + } + + // If no parent/source found, repo is not a fork + if (result.length === 1) return null; + + return result; +} diff --git a/packages/web/server/lib/github/routes.js b/packages/web/server/lib/github/routes.js index 45579f9c..81e44e33 100644 --- a/packages/web/server/lib/github/routes.js +++ b/packages/web/server/lib/github/routes.js @@ -1,3 +1,42 @@ +const PR_STATUS_CACHE_TTL_MS = 90_000; +const PR_STATUS_CACHE_MAX_ENTRIES = 200; +const prStatusCache = new Map(); + +function getRequestedRepo(req) { + const owner = typeof req.query?.owner === 'string' ? req.query.owner.trim() : ''; + const repo = typeof req.query?.repo === 'string' ? req.query.repo.trim() : ''; + return owner && repo ? { owner, repo } : null; +} + +async function resolveRepoForRequest(octokit, directory, requestedRepo) { + const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!requestedRepo) { + return repo; + } + if (repo?.owner === requestedRepo.owner && repo?.repo === requestedRepo.repo) { + return requestedRepo; + } + + const { resolveRepoNetwork } = await import('./repo/fork-detection.js'); + const network = await resolveRepoNetwork(octokit, directory).catch(() => null); + const allowed = Array.isArray(network) + ? network.some((item) => item?.owner === requestedRepo.owner && item?.repo === requestedRepo.repo) + : false; + return allowed ? requestedRepo : null; +} + +function setPrStatusCache(key, data, fetchedAt) { + // Evict oldest entry when cache exceeds max size + if (prStatusCache.size >= PR_STATUS_CACHE_MAX_ENTRIES && !prStatusCache.has(key)) { + const oldest = prStatusCache.entries().next().value; + if (oldest) { + prStatusCache.delete(oldest[0]); + } + } + prStatusCache.set(key, { data, fetchedAt }); +} + export function registerGitHubRoutes(app) { let githubLibraries = null; const getGitHubLibraries = async () => { @@ -249,10 +288,28 @@ export function registerGitHubRoutes(app) { const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; const branch = typeof req.query?.branch === 'string' ? req.query.branch.trim() : ''; const remote = typeof req.query?.remote === 'string' ? req.query.remote.trim() : 'origin'; + const force = req.query?.force === 'true' || req.query?.force === '1'; if (!directory || !branch) { return res.status(400).json({ error: 'directory and branch are required' }); } + // Check cache (skip when force=true to allow manual refresh bypass) + const cacheKey = `${directory}::${branch}::${remote}`; + const cached = prStatusCache.get(cacheKey); + if (!force && cached && Date.now() - cached.fetchedAt < PR_STATUS_CACHE_TTL_MS) { + return res.json(cached.data); + } + + // Intercept res.json to cache successful responses before sending + // Only caches responses with connected:true — error/edge-case responses are not cached + const originalJson = res.json.bind(res); + res.json = (data) => { + if (data && data.connected === true) { + setPrStatusCache(cacheKey, data, Date.now()); + } + return originalJson(data); + }; + const { getOctokitOrNull, getGitHubAuth } = await getGitHubLibraries(); const octokit = getOctokitOrNull(); if (!octokit) { @@ -426,6 +483,10 @@ export function registerGitHubRoutes(app) { const remote = typeof req.body?.remote === 'string' ? req.body.remote.trim() : 'origin'; // headRemote = source repo (where head branch lives, e.g., 'origin' for forks) const headRemote = typeof req.body?.headRemote === 'string' ? req.body.headRemote.trim() : ''; + // targetRepo = explicit target repo (alternative to remote, for auto-detected upstream) + const targetRepo = req.body?.targetRepo && typeof req.body.targetRepo.owner === 'string' && typeof req.body.targetRepo.repo === 'string' + ? { owner: req.body.targetRepo.owner.trim(), repo: req.body.targetRepo.repo.trim() } + : null; if (!directory || !title || !head || !requestedBase) { return res.status(400).json({ error: 'directory, title, head, base are required' }); } @@ -437,7 +498,13 @@ export function registerGitHubRoutes(app) { } const { resolveGitHubRepoFromDirectory } = await import('./index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory, remote); + let repo; + if (targetRepo) { + repo = targetRepo; + } else { + const resolved = await resolveGitHubRepoFromDirectory(directory, remote); + repo = resolved.repo; + } if (!repo) { return res.status(400).json({ error: 'Unable to resolve GitHub repo from git remote' }); } @@ -511,25 +578,28 @@ export function registerGitHubRoutes(app) { // For fork workflows: we need to determine the correct head reference let headRef = head; + let headRepo = null; - if (sourceRemote && sourceRemote !== remote) { + if (sourceRemote) { // The branch is on a different remote than the target - this is a cross-repo PR - const { repo: headRepo } = await resolveGitHubRepoFromDirectory(directory, sourceRemote); - if (headRepo) { - // Always use owner:branch format for cross-repo PRs - // GitHub API requires this when head is from a different repo/fork - if (headRepo.owner !== repo.owner || headRepo.repo !== repo.repo) { - headRef = `${headRepo.owner}:${head}`; - } + const resolved = await resolveGitHubRepoFromDirectory(directory, sourceRemote); + headRepo = resolved.repo; + if (!headRepo) { + return res.status(400).json({ + error: `Cannot resolve GitHub repo for remote "${sourceRemote}". Check that the remote URL is a valid GitHub repository.`, + }); + } + // Always use owner:branch format for cross-repo PRs + // GitHub API requires this when head is from a different repo/fork + if (headRepo.owner !== repo.owner || headRepo.repo !== repo.repo) { + headRef = `${headRepo.owner}:${head}`; } } // For cross-repo PRs, verify the branch exists on the head repo first if (headRef.includes(':')) { const [headOwner] = headRef.split(':'); - const headRepoName = sourceRemote - ? (await resolveGitHubRepoFromDirectory(directory, sourceRemote)).repo?.repo - : repo.repo; + const headRepoName = headRepo?.repo || repo.repo; if (headRepoName) { try { @@ -564,6 +634,11 @@ export function registerGitHubRoutes(app) { return res.status(500).json({ error: 'Failed to create PR' }); } + // Invalidate PR status cache so subsequent prStatus calls fetch fresh data + const headBranch = head.includes(':') ? head.split(':')[1] || head : head; + const createCacheKey = `${directory}::${headBranch}::${remote}`; + prStatusCache.delete(createCacheKey); + return res.json({ number: pr.number, title: pr.title, @@ -766,6 +841,106 @@ export function registerGitHubRoutes(app) { } }); + // ================= GitHub Repo APIs ================= + + app.get('/api/github/repo/upstream', async (req, res) => { + try { + const directory = typeof req.query?.directory === 'string' ? req.query.directory.trim() : ''; + if (!directory) { + return res.status(400).json({ error: 'directory is required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ connected: false, isFork: false, upstream: null }); + } + + const { resolveRepoNetwork } = await import('./repo/fork-detection.js'); + const network = await resolveRepoNetwork(octokit, directory); + + if (!network || network.length <= 1) { + return res.json({ connected: true, isFork: false, upstream: null }); + } + + const upstream = network.find((r) => r.source === 'upstream') || null; + let defaultBranch = 'main'; + let defaultBranchSha = null; + if (upstream) { + try { + const metadata = await octokit.rest.repos.get({ owner: upstream.owner, repo: upstream.repo }); + defaultBranch = metadata?.data?.default_branch || 'main'; + const ref = await octokit.rest.git.getRef({ owner: upstream.owner, repo: upstream.repo, ref: `heads/${defaultBranch}` }); + defaultBranchSha = ref?.data?.object?.sha || null; + } catch { + // Fall back if metadata/ref fetch fails + } + } + + // Check if a configured git remote points to the upstream repo + let upstreamRemoteName = null; + if (upstream) { + try { + const { getRemotes } = await import('../git/index.js'); + const remotes = await getRemotes(directory); + for (const r of remotes) { + if (r?.name) { + const resolved = await resolveGitHubRepoFromDirectory(directory, r.name).catch(() => ({ repo: null })); + if (resolved.repo && resolved.repo.owner === upstream.owner && resolved.repo.repo === upstream.repo) { + upstreamRemoteName = r.name; + break; + } + } + } + } catch { + // Ignore errors finding remote name + } + } + + return res.json({ + connected: true, + isFork: Boolean(upstream), + upstream: upstream ? { owner: upstream.owner, repo: upstream.repo, url: upstream.url, defaultBranch, defaultBranchSha, remoteName: upstreamRemoteName } : null, + }); + } catch (error) { + console.error('Failed to detect upstream repo:', error); + return res.status(500).json({ error: error.message || 'Failed to detect upstream repo' }); + } + }); + + app.get('/api/github/repo/branches', async (req, res) => { + try { + const owner = typeof req.query?.owner === 'string' ? req.query.owner.trim() : ''; + const repo = typeof req.query?.repo === 'string' ? req.query.repo.trim() : ''; + if (!owner || !repo) { + return res.status(400).json({ error: 'owner and repo are required' }); + } + + const { getOctokitOrNull } = await getGitHubLibraries(); + const octokit = getOctokitOrNull(); + if (!octokit) { + return res.json({ branches: [] }); + } + + const branches = []; + let page = 1; + while (true) { + const response = await octokit.rest.repos.listBranches({ owner, repo, per_page: 100, page }); + if (!response.data || response.data.length === 0) break; + for (const branch of response.data) { + branches.push(branch.name); + } + if (response.data.length < 100) break; + page++; + } + + return res.json({ branches }); + } catch (error) { + console.error('Failed to fetch repo branches:', error); + return res.status(500).json({ error: error.message || 'Failed to fetch repo branches' }); + } + }); + // ================= GitHub Issue APIs ================= app.get('/api/github/issues/list', async (req, res) => { @@ -783,41 +958,60 @@ export function registerGitHubRoutes(app) { } const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { resolveRepoNetwork } = await import('./repo/fork-detection.js'); + + const repoNetwork = await resolveRepoNetwork(octokit, directory); const { repo } = await resolveGitHubRepoFromDirectory(directory); if (!repo) { return res.json({ connected: true, repo: null, issues: [] }); } - const list = await octokit.rest.issues.listForRepo({ - owner: repo.owner, - repo: repo.repo, - state: 'open', - per_page: 50, - page: Number.isFinite(page) && page > 0 ? page : 1, - }); - const link = typeof list?.headers?.link === 'string' ? list.headers.link : ''; - 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) - : [], - })); + const effectivePage = Number.isFinite(page) && page > 0 ? page : 1; + const reposToQuery = repoNetwork || [{ ...repo, source: 'origin' }]; - return res.json({ connected: true, repo, issues, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore }); + const queryRepo = async (repoRef) => { + try { + const list = await octokit.rest.issues.listForRepo({ + owner: repoRef.owner, + repo: repoRef.repo, + state: 'open', + per_page: 50, + page: effectivePage, + }); + const link = typeof list?.headers?.link === 'string' ? list.headers.link : ''; + 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 }, + })); + return { issues, hasMore }; + } catch (error) { + console.warn(`Failed to list issues for ${repoRef.owner}/${repoRef.repo}:`, error?.message || error); + return { issues: [], hasMore: false }; + } + }; + + const results = await Promise.all(reposToQuery.map(queryRepo)); + const allIssues = results.flatMap((r) => r.issues); + const anyHasMore = results.some((r) => r.hasMore); + + return res.json({ connected: true, repo, issues: allIssues, page: effectivePage, hasMore: anyHasMore }); } catch (error) { console.error('Failed to list GitHub issues:', error); return res.status(500).json({ error: error.message || 'Failed to list GitHub issues' }); @@ -838,8 +1032,8 @@ export function registerGitHubRoutes(app) { return res.json({ connected: false }); } - const { resolveGitHubRepoFromDirectory } = await import('./index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory); + const requestedRepo = getRequestedRepo(req); + const repo = await resolveRepoForRequest(octokit, directory, requestedRepo); if (!repo) { return res.json({ connected: true, repo: null, issue: null }); } @@ -899,8 +1093,8 @@ export function registerGitHubRoutes(app) { return res.json({ connected: false }); } - const { resolveGitHubRepoFromDirectory } = await import('./index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory); + const requestedRepo = getRequestedRepo(req); + const repo = await resolveRepoForRequest(octokit, directory, requestedRepo); if (!repo) { return res.json({ connected: true, repo: null, comments: [] }); } @@ -945,61 +1139,78 @@ export function registerGitHubRoutes(app) { } const { resolveGitHubRepoFromDirectory } = await import('./index.js'); + const { resolveRepoNetwork } = await import('./repo/fork-detection.js'); + + const repoNetwork = await resolveRepoNetwork(octokit, directory); const { repo } = await resolveGitHubRepoFromDirectory(directory); if (!repo) { return res.json({ connected: true, repo: null, prs: [] }); } - const list = await octokit.rest.pulls.list({ - owner: repo.owner, - repo: repo.repo, - state: 'open', - per_page: 50, - page: Number.isFinite(page) && page > 0 ? page : 1, - }); + const effectivePage = Number.isFinite(page) && page > 0 ? page : 1; + const reposToQuery = repoNetwork || [{ ...repo, source: 'origin' }]; - const link = typeof list?.headers?.link === 'string' ? list.headers.link : ''; - const hasMore = /rel="next"/.test(link); + const queryRepo = async (repoRef) => { + try { + const list = await octokit.rest.pulls.list({ + owner: repoRef.owner, + repo: repoRef.repo, + state: 'open', + per_page: 50, + page: effectivePage, + }); + 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 }, + }; + }); + return { prs, hasMore }; + } catch (error) { + console.warn(`Failed to list PRs for ${repoRef.owner}/${repoRef.repo}:`, error?.message || error); + return { prs: [], hasMore: false }; + } + }; - 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, - }; - }); + const results = await Promise.all(reposToQuery.map(queryRepo)); + const allPrs = results.flatMap((r) => r.prs); + const anyHasMore = results.some((r) => r.hasMore); - return res.json({ connected: true, repo, prs, page: Number.isFinite(page) && page > 0 ? page : 1, hasMore }); + return res.json({ connected: true, repo, prs: allPrs, page: effectivePage, hasMore: anyHasMore }); } catch (error) { if (error?.status === 401) { const { clearGitHubAuth } = await getGitHubLibraries(); clearGitHubAuth(); return res.json({ connected: false }); } - console.error('Failed to list GitHub PRs:', error); - return res.status(500).json({ error: error.message || 'Failed to list GitHub PRs' }); + console.error('Failed to list GitHub pull requests:', error); + return res.status(500).json({ error: error.message || 'Failed to list GitHub pull requests' }); } }); @@ -1019,8 +1230,8 @@ export function registerGitHubRoutes(app) { return res.json({ connected: false }); } - const { resolveGitHubRepoFromDirectory } = await import('./index.js'); - const { repo } = await resolveGitHubRepoFromDirectory(directory); + const requestedRepo = getRequestedRepo(req); + const repo = await resolveRepoForRequest(octokit, directory, requestedRepo); if (!repo) { return res.json({ connected: true, repo: null, pr: null }); } diff --git a/packages/web/src/api/github.ts b/packages/web/src/api/github.ts index be66acd0..91877362 100644 --- a/packages/web/src/api/github.ts +++ b/packages/web/src/api/github.ts @@ -14,6 +14,7 @@ import type { GitHubPullRequestReadyResult, GitHubPullRequestUpdateInput, GitHubPullRequestStatus, + GitHubRepoUpstreamResult, GitHubDeviceFlowComplete, GitHubDeviceFlowStart, GitHubUserSummary, @@ -90,11 +91,12 @@ export const createWebGitHubAPI = (): GitHubAPI => ({ return payload; }, - async prStatus(directory: string, branch: string, remote?: string): Promise { + async prStatus(directory: string, branch: string, remote?: string, options?: { force?: boolean }): Promise { const params = new URLSearchParams({ directory, branch, ...(remote ? { remote } : {}), + ...(options?.force ? { force: 'true' } : {}), }); const response = await fetch( `/api/github/pr/status?${params.toString()}`, @@ -159,6 +161,30 @@ export const createWebGitHubAPI = (): GitHubAPI => ({ return body; }, + async repoUpstream(directory: string): Promise { + const response = await fetch( + `/api/github/repo/upstream?directory=${encodeURIComponent(directory)}`, + { method: 'GET', headers: { Accept: 'application/json' } } + ); + const body = await jsonOrNull(response); + if (!response.ok || !body) { + throw new Error(body?.error || response.statusText || 'Failed to detect upstream repo'); + } + return body; + }, + + async repoBranches(owner: string, repo: string): Promise { + const response = await fetch( + `/api/github/repo/branches?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`, + { method: 'GET', headers: { Accept: 'application/json' } } + ); + const body = await jsonOrNull<{ branches?: string[]; error?: string }>(response); + if (!response.ok || !body) { + throw new Error(body?.error || response.statusText || 'Failed to fetch repo branches'); + } + return body.branches ?? []; + }, + async prsList(directory: string, options?: { page?: number }): Promise { const page = options?.page ?? 1; const response = await fetch( @@ -175,7 +201,7 @@ export const createWebGitHubAPI = (): GitHubAPI => ({ async prContext( directory: string, number: number, - options?: { includeDiff?: boolean; includeCheckDetails?: boolean } + options?: { includeDiff?: boolean; includeCheckDetails?: boolean; sourceRepo?: { owner: string; repo: string } | null } ): Promise { const url = new URL('/api/github/pulls/context', window.location.origin); url.searchParams.set('directory', directory); @@ -186,6 +212,10 @@ export const createWebGitHubAPI = (): GitHubAPI => ({ if (options?.includeCheckDetails) { url.searchParams.set('checkDetails', '1'); } + if (options?.sourceRepo?.owner && options.sourceRepo.repo) { + url.searchParams.set('owner', options.sourceRepo.owner); + url.searchParams.set('repo', options.sourceRepo.repo); + } const response = await fetch(url.toString(), { method: 'GET', headers: { Accept: 'application/json' } }); const body = await jsonOrNull(response); if (!response.ok || !body) { @@ -207,11 +237,15 @@ export const createWebGitHubAPI = (): GitHubAPI => ({ return payload; }, - async issueGet(directory: string, number: number): Promise { - const response = await fetch( - `/api/github/issues/get?directory=${encodeURIComponent(directory)}&number=${encodeURIComponent(String(number))}`, - { method: 'GET', headers: { Accept: 'application/json' } } - ); + async issueGet(directory: string, number: number, options?: { sourceRepo?: { owner: string; repo: string } | null }): Promise { + const url = new URL('/api/github/issues/get', window.location.origin); + url.searchParams.set('directory', directory); + url.searchParams.set('number', String(number)); + if (options?.sourceRepo?.owner && options.sourceRepo.repo) { + url.searchParams.set('owner', options.sourceRepo.owner); + url.searchParams.set('repo', options.sourceRepo.repo); + } + const response = await fetch(url.toString(), { method: 'GET', headers: { Accept: 'application/json' } }); const payload = await jsonOrNull(response); if (!response.ok || !payload) { throw new Error(payload?.error || response.statusText || 'Failed to load issue'); @@ -219,11 +253,15 @@ export const createWebGitHubAPI = (): GitHubAPI => ({ return payload; }, - async issueComments(directory: string, number: number): Promise { - const response = await fetch( - `/api/github/issues/comments?directory=${encodeURIComponent(directory)}&number=${encodeURIComponent(String(number))}`, - { method: 'GET', headers: { Accept: 'application/json' } } - ); + async issueComments(directory: string, number: number, options?: { sourceRepo?: { owner: string; repo: string } | null }): Promise { + const url = new URL('/api/github/issues/comments', window.location.origin); + url.searchParams.set('directory', directory); + url.searchParams.set('number', String(number)); + if (options?.sourceRepo?.owner && options.sourceRepo.repo) { + url.searchParams.set('owner', options.sourceRepo.owner); + url.searchParams.set('repo', options.sourceRepo.repo); + } + const response = await fetch(url.toString(), { method: 'GET', headers: { Accept: 'application/json' } }); const payload = await jsonOrNull(response); if (!response.ok || !payload) { throw new Error(payload?.error || response.statusText || 'Failed to load issue comments');