feat: fork-aware issue/PR listing & OpenCode startup loading indicator (#1061)

* 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 <islamnofl.official@gmail.com>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Islam Nofl
2026-04-29 12:03:39 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 17650becc0
commit 21253d7fc2
27 changed files with 1042 additions and 296 deletions
+143 -69
View File
@@ -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<any>;
@@ -341,6 +343,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
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<ModelControlsProps> = ({
return (
<Tooltip delayDuration={1000}>
{!isCompact ? (
<DropdownMenu open={agentMenuOpen} onOpenChange={handleModelMenuOpenChange}>
<DropdownMenu open={isReady && agentMenuOpen} onOpenChange={isReady ? handleModelMenuOpenChange : undefined}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<div
@@ -2661,7 +2665,18 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
buttonHeight
)}
>
{currentProviderId ? (
{!isReady ? (
<>
<RiLoader4Line className={cn(controlIconSize, 'animate-spin text-muted-foreground flex-shrink-0')} />
<span className={cn(
'model-controls__model-label',
controlTextSize,
'font-medium whitespace-nowrap text-muted-foreground min-w-0'
)}>
{readinessLabel}
</span>
</>
) : currentProviderId ? (
<>
<ProviderLogo
providerId={currentProviderId}
@@ -2672,6 +2687,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
) : (
<RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} />
)}
{isReady && (
<span
ref={modelLabelRef}
key={`${currentProviderId}-${currentModelId}`}
@@ -2686,6 +2702,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
{currentModelDisplayName}
</span>
</span>
)}
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -2891,35 +2908,47 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
) : (
<button
type="button"
onClick={() => setActiveMobilePanel('model')}
onTouchStart={() => handleLongPressStart('model')}
onTouchEnd={handleLongPressEnd}
onTouchCancel={handleLongPressEnd}
onClick={isReady ? () => setActiveMobilePanel('model') : undefined}
onTouchStart={isReady ? () => handleLongPressStart('model') : undefined}
onTouchEnd={isReady ? handleLongPressEnd : undefined}
onTouchCancel={isReady ? handleLongPressEnd : undefined}
disabled={!isReady}
className={cn(
'model-controls__model-trigger flex items-center gap-1.5 min-w-0 focus:outline-none',
'cursor-pointer hover:bg-transparent hover:opacity-70',
isReady ? 'cursor-pointer hover:bg-transparent hover:opacity-70' : 'opacity-60 cursor-not-allowed',
buttonHeight
)}
>
{currentProviderId ? (
<ProviderLogo
providerId={currentProviderId}
className={cn(controlIconSize, 'flex-shrink-0')}
/>
{!isReady ? (
<>
<RiLoader4Line className={cn(controlIconSize, 'animate-spin text-muted-foreground flex-shrink-0')} />
<span className="typography-micro font-medium text-muted-foreground min-w-0">
{readinessLabel}
</span>
</>
) : (
<RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} />
<>
{currentProviderId ? (
<ProviderLogo
providerId={currentProviderId}
className={cn(controlIconSize, 'flex-shrink-0')}
/>
) : (
<RiPencilAiLine className={cn(controlIconSize, 'text-muted-foreground')} />
)}
<span
ref={modelLabelRef}
className={cn(
'model-controls__model-label typography-micro font-medium overflow-hidden min-w-0',
isMobile ? 'max-w-[120px]' : 'max-w-[220px]',
)}
>
<span className={cn('marquee-text', isModelLabelTruncated && 'marquee-text--active')}>
{currentModelDisplayName}
</span>
</span>
</>
)}
<span
ref={modelLabelRef}
className={cn(
'model-controls__model-label typography-micro font-medium overflow-hidden min-w-0',
isMobile ? 'max-w-[120px]' : 'max-w-[220px]',
)}
>
<span className={cn('marquee-text', isModelLabelTruncated && 'marquee-text--active')}>
{currentModelDisplayName}
</span>
</span>
</button>
)}
{renderModelTooltipContent()}
@@ -3056,7 +3085,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
};
const renderVariantSelector = () => {
if (!hasVariants) {
if (!isReady || !hasVariants) {
return null;
}
@@ -3154,32 +3183,54 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return (
<div className="flex items-center gap-2 min-w-0">
<Tooltip delayDuration={1000}>
<DropdownMenu open={isAgentSelectorOpen} onOpenChange={setIsAgentSelectorOpen}>
<DropdownMenu open={isReady && isAgentSelectorOpen} onOpenChange={isReady ? setIsAgentSelectorOpen : undefined}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<div className={cn(
'flex items-center gap-1.5 transition-colors cursor-pointer hover:bg-transparent hover:opacity-70 min-w-0',
buttonHeight
)}>
<RiAiAgentLine
className={cn(
controlIconSize,
'flex-shrink-0',
uiAgentName ? '' : 'text-muted-foreground'
)}
style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined}
/>
<span
className={cn(
'model-controls__agent-label',
controlTextSize,
'font-medium min-w-0 truncate',
isDesktop ? 'max-w-[220px]' : undefined
)}
style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined}
>
{getAgentDisplayName()}
</span>
{!isReady ? (
<>
<RiLoader4Line
className={cn(
controlIconSize,
'flex-shrink-0 animate-spin text-muted-foreground'
)}
/>
<span
className={cn(
'model-controls__agent-label',
controlTextSize,
'font-medium min-w-0 truncate text-muted-foreground'
)}
>
{readinessLabel}
</span>
</>
) : (
<>
<RiAiAgentLine
className={cn(
controlIconSize,
'flex-shrink-0',
uiAgentName ? '' : 'text-muted-foreground'
)}
style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined}
/>
<span
className={cn(
'model-controls__agent-label',
controlTextSize,
'font-medium min-w-0 truncate',
isDesktop ? 'max-w-[220px]' : undefined
)}
style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined}
>
{getAgentDisplayName()}
</span>
</>
)}
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -3257,35 +3308,58 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return (
<button
type="button"
onClick={() => setActiveMobilePanel('agent')}
onTouchStart={() => handleLongPressStart('agent')}
onTouchEnd={handleLongPressEnd}
onTouchCancel={handleLongPressEnd}
onClick={isReady ? () => setActiveMobilePanel('agent') : undefined}
onTouchStart={isReady ? () => handleLongPressStart('agent') : undefined}
onTouchEnd={isReady ? handleLongPressEnd : undefined}
onTouchCancel={isReady ? handleLongPressEnd : undefined}
disabled={!isReady}
className={cn(
'model-controls__agent-trigger flex items-center gap-1.5 transition-colors min-w-0 focus:outline-none',
buttonHeight,
'cursor-pointer hover:bg-transparent hover:opacity-70',
isReady ? 'cursor-pointer hover:bg-transparent hover:opacity-70' : 'opacity-60 cursor-not-allowed',
)}
>
<RiAiAgentLine
className={cn(
controlIconSize,
'flex-shrink-0',
uiAgentName ? '' : 'text-muted-foreground'
)}
style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined}
/>
<span
className={cn(
'model-controls__agent-label',
controlTextSize,
'font-medium truncate min-w-0',
isMobile && 'max-w-[60px]'
)}
style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined}
>
{getAgentDisplayName()}
</span>
{!isReady ? (
<>
<RiLoader4Line
className={cn(
controlIconSize,
'flex-shrink-0 animate-spin text-muted-foreground'
)}
/>
<span
className={cn(
'model-controls__agent-label',
controlTextSize,
'font-medium truncate min-w-0 text-muted-foreground'
)}
>
{readinessLabel}
</span>
</>
) : (
<>
<RiAiAgentLine
className={cn(
controlIconSize,
'flex-shrink-0',
uiAgentName ? '' : 'text-muted-foreground'
)}
style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined}
/>
<span
className={cn(
'model-controls__agent-label',
controlTextSize,
'font-medium truncate min-w-0',
isMobile && 'max-w-[60px]'
)}
style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined}
>
{getAgentDisplayName()}
</span>
</>
)}
</button>
);
};
@@ -146,7 +146,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
<button
type="button"
onClick={handleCollapse}
className="absolute top-0 right-0 flex items-center justify-center rounded-sm p-0.5 text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)] hover:bg-[var(--interactive-hover)] transition-colors"
className="absolute top-0 right-0 z-10 flex items-center justify-center rounded-sm bg-[var(--surface-elevated)] p-0.5 text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)] hover:bg-[var(--interactive-hover)] transition-colors"
aria-label="Collapse"
>
<RiArrowUpSLine className="h-3.5 w-3.5" />
@@ -11,7 +11,7 @@ import { Input } from '@/components/ui/input';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { RiArrowDownSLine, RiArrowRightSLine, RiCheckLine, RiCloseLine, RiPencilAiLine, RiSearchLine, RiStarFill, RiStarLine, RiTimeLine } from '@remixicon/react';
import { RiArrowDownSLine, RiArrowRightSLine, RiCheckLine, RiCloseLine, RiLoader4Line, RiPencilAiLine, RiSearchLine, RiStarFill, RiStarLine, RiTimeLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
@@ -19,6 +19,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useModelLists } from '@/hooks/useModelLists';
import type { ModelMetadata } from '@/types';
import { useI18n } from '@/lib/i18n';
import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness';
type ProviderModel = Record<string, unknown> & { id?: string; name?: string };
@@ -58,6 +59,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
placeholder
}) => {
const { t } = useI18n();
const { isReady, isUnavailable } = useOpenCodeReadiness();
const providers = useConfigStore((state) => state.providers);
const modelsMetadata = useConfigStore((state) => state.modelsMetadata);
const isMobile = useUIStore(state => state.isMobile);
@@ -495,14 +497,21 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
{isActuallyMobile ? (
<button
type="button"
onClick={() => setIsMobilePanelOpen(true)}
onClick={isReady ? () => setIsMobilePanelOpen(true) : undefined}
disabled={!isReady}
className={cn(
'flex w-full items-center justify-between gap-2 rounded-lg border border-border/40 bg-[var(--surface-elevated)] px-2 py-1.5 text-left',
!isReady && 'opacity-60 cursor-not-allowed',
className
)}
>
<div className="flex items-center gap-2">
{providerId ? (
{!isReady ? (
<>
<RiLoader4Line className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<span className="typography-meta text-muted-foreground">{isUnavailable ? t('common.unavailable') : t('common.loading')}</span>
</>
) : providerId ? (
<ProviderLogo
providerId={providerId}
className="h-3.5 w-3.5"
@@ -510,33 +519,46 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
) : (
<RiPencilAiLine className="h-3 w-3 text-muted-foreground" />
)}
<span className="typography-meta font-medium text-foreground">
{providerId && modelId ? `${providerId}/${modelId}` : (placeholder || t('settings.agents.modelSelector.selectPlaceholder'))}
</span>
{isReady && (
<span className="typography-meta font-medium text-foreground">
{providerId && modelId ? `${providerId}/${modelId}` : (placeholder || t('settings.agents.modelSelector.selectPlaceholder'))}
</span>
)}
</div>
<RiArrowDownSLine className="h-3 w-3 text-muted-foreground" />
</button>
) : (
<DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
<DropdownMenu open={isReady && isDropdownOpen} onOpenChange={isReady ? setIsDropdownOpen : undefined}>
<DropdownMenuTrigger asChild>
<div className={cn(
'border-input data-[placeholder]:text-muted-foreground flex items-center justify-between gap-2 rounded-lg border bg-transparent px-2 py-2 typography-ui-label whitespace-nowrap shadow-none outline-none hover:bg-interactive-hover data-[popup-open]:bg-interactive-active h-6 w-fit',
className
)}>
{providerId ? (
{!isReady ? (
<>
<ProviderLogo
providerId={providerId}
className="h-3.5 w-3.5 flex-shrink-0"
/>
<RiPencilAiLine className="h-3 w-3 text-primary/60 hidden" />
<RiLoader4Line className="h-3.5 w-3.5 animate-spin text-muted-foreground flex-shrink-0" />
<span className="typography-ui-label font-normal whitespace-nowrap text-muted-foreground">
{isUnavailable ? t('common.unavailable') : t('common.loading')}
</span>
</>
) : (
<RiPencilAiLine className="h-3.5 w-3.5 text-muted-foreground" />
<>
{providerId ? (
<>
<ProviderLogo
providerId={providerId}
className="h-3.5 w-3.5 flex-shrink-0"
/>
<RiPencilAiLine className="h-3 w-3 text-primary/60 hidden" />
</>
) : (
<RiPencilAiLine className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="typography-ui-label font-normal whitespace-nowrap text-foreground">
{providerId && modelId ? `${providerId}/${modelId}` : (placeholder || t('settings.agents.modelSelector.notSelected'))}
</span>
</>
)}
<span className="typography-ui-label font-normal whitespace-nowrap text-foreground">
{providerId && modelId ? `${providerId}/${modelId}` : (placeholder || t('settings.agents.modelSelector.notSelected'))}
</span>
<RiArrowDownSLine className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
</div>
</DropdownMenuTrigger>
@@ -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<AgentSelectorProps> = ({
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<AgentSelectorProps> = ({
{isActuallyMobile ? (
<button
type="button"
onClick={() => setIsMobilePanelOpen(true)}
onClick={isReady ? () => setIsMobilePanelOpen(true) : undefined}
disabled={!isReady}
className={cn(
'flex w-full items-center justify-between gap-2 rounded-lg border border-border/40 bg-background/95 px-2 py-1.5 text-left',
!isReady && 'opacity-60 cursor-not-allowed',
className
)}
>
<div className="flex items-center gap-2">
<RiRobot2Line className="h-3.5 w-3.5 text-muted-foreground" />
<span className="typography-meta font-medium text-foreground">
{agentName || t('settings.commands.agentSelector.selectAgentPlaceholder')}
</span>
{!isReady ? (
<>
<RiLoader4Line className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<span className="typography-meta text-muted-foreground">{isUnavailable ? t('common.unavailable') : t('common.loading')}</span>
</>
) : (
<>
<RiRobot2Line className="h-3.5 w-3.5 text-muted-foreground" />
<span className="typography-meta font-medium text-foreground">
{agentName || t('settings.commands.agentSelector.selectAgentPlaceholder')}
</span>
</>
)}
</div>
<RiArrowDownSLine className="h-3 w-3 text-muted-foreground" />
</button>
) : !isReady ? (
<div className={cn(
'flex items-center gap-2 px-2 rounded-lg bg-interactive-selection/20 border border-border/20 h-6 w-fit opacity-60',
className
)}>
<RiLoader4Line className="h-3 w-3 animate-spin text-muted-foreground flex-shrink-0" />
<span className="typography-micro font-medium whitespace-nowrap text-muted-foreground">
{isUnavailable ? t('common.unavailable') : t('common.loading')}
</span>
</div>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -347,7 +347,7 @@ export function GitHubIntegrationDialog({
{filteredIssues.length > 0 ? (
filteredIssues.map(issue => (
<button
key={issue.number}
key={`${issue.sourceRepo?.owner ?? ''}-${issue.sourceRepo?.repo ?? ''}-${issue.number}`}
onClick={() => handleSelectIssue(issue)}
className={cn(
'w-full text-left px-2 py-1.5 rounded transition-colors',
@@ -358,7 +358,14 @@ export function GitHubIntegrationDialog({
>
<div className="flex items-start gap-2">
<span className="text-muted-foreground shrink-0 typography-micro">#{issue.number}</span>
<span className="typography-small line-clamp-2">{issue.title}</span>
<div className="min-w-0 flex-1">
<span className="typography-small line-clamp-2">{issue.title}</span>
{issue.sourceRepo?.source === 'upstream' ? (
<span className="typography-micro px-1 py-0.5 rounded bg-status-info/10 text-status-info mt-0.5 inline-block">
{issue.sourceRepo.owner}/{issue.sourceRepo.repo}
</span>
) : null}
</div>
</div>
</button>
))
@@ -398,7 +405,7 @@ export function GitHubIntegrationDialog({
return (
<button
key={pr.number}
key={`${pr.sourceRepo?.owner ?? ''}-${pr.sourceRepo?.repo ?? ''}-${pr.number}`}
onClick={() => !blocked && handleSelectPr(pr)}
disabled={blocked}
className={cn(
@@ -418,6 +425,11 @@ export function GitHubIntegrationDialog({
<span className="typography-micro text-muted-foreground">
{pr.head} {pr.base}
</span>
{pr.sourceRepo?.source === 'upstream' ? (
<span className="typography-micro px-1 py-0.5 rounded bg-status-info/10 text-status-info">
{pr.sourceRepo.owner}/{pr.sourceRepo.repo}
</span>
) : null}
{blocked && validation?.error && (
<span className="typography-micro text-destructive">
{validation.error}
@@ -33,7 +33,7 @@ import { opencodeClient } from '@/lib/opencode/client';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult, GitHubIssueSummary } from '@/lib/api/types';
import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult, GitHubIssueSummary, GitHubRepoSelector } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
const parseIssueNumber = (value: string): number | null => {
@@ -90,13 +90,7 @@ export function GitHubIssuePickerDialog({
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const projectDirectory = React.useMemo(() => {
const fromDirectoryStore = currentDirectory?.trim();
if (fromDirectoryStore) {
return fromDirectoryStore;
}
const fromActiveProject = activeProject?.path?.trim();
return fromActiveProject || null;
return activeProject?.path?.trim() || currentDirectory?.trim() || null;
}, [activeProject?.path, currentDirectory]);
const [query, setQuery] = React.useState('');
@@ -278,7 +272,7 @@ export function GitHubIssuePickerDialog({
return settingsDefaultVariant;
}, []);
const startSession = React.useCallback(async (issueNumber: number) => {
const startSession = React.useCallback(async (issueNumber: number, sourceRepo?: GitHubRepoSelector | null) => {
if (mode === 'select') {
// In select mode, fetch full issue details and return via onSelect
if (!projectDirectory) {
@@ -292,7 +286,7 @@ export function GitHubIssuePickerDialog({
if (startingIssueNumber) return;
setStartingIssueNumber(issueNumber);
try {
const issueRes = await github.issueGet(projectDirectory, issueNumber);
const issueRes = await github.issueGet(projectDirectory, issueNumber, { sourceRepo });
if (issueRes.connected === false) {
toast.error(t('session.githubIssuePicker.error.notConnected'));
return;
@@ -309,7 +303,7 @@ export function GitHubIssuePickerDialog({
return;
}
const commentsRes = await github.issueComments(projectDirectory, issueNumber);
const commentsRes = await github.issueComments(projectDirectory, issueNumber, { sourceRepo });
if (commentsRes.connected === false) {
toast.error(t('session.githubIssuePicker.error.notConnected'));
return;
@@ -352,7 +346,7 @@ export function GitHubIssuePickerDialog({
if (startingIssueNumber) return;
setStartingIssueNumber(issueNumber);
try {
const issueRes = await github.issueGet(projectDirectory, issueNumber);
const issueRes = await github.issueGet(projectDirectory, issueNumber, { sourceRepo });
if (issueRes.connected === false) {
toast.error(t('session.githubIssuePicker.error.notConnected'));
return;
@@ -369,7 +363,7 @@ export function GitHubIssuePickerDialog({
return;
}
const commentsRes = await github.issueComments(projectDirectory, issueNumber);
const commentsRes = await github.issueComments(projectDirectory, issueNumber, { sourceRepo });
if (commentsRes.connected === false) {
toast.error(t('session.githubIssuePicker.error.notConnected'));
return;
@@ -570,19 +564,26 @@ export function GitHubIssuePickerDialog({
{filtered.map((issue) => (
<div
key={issue.number}
key={`${issue.sourceRepo?.owner ?? ''}-${issue.sourceRepo?.repo ?? ''}-${issue.number}`}
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
startingIssueNumber === issue.number && 'bg-interactive-selection/30'
)}
onClick={() => void startSession(issue.number)}
onClick={() => void startSession(issue.number, issue.sourceRepo)}
>
<span className="typography-meta text-muted-foreground w-12 text-right flex-shrink-0">
#{issue.number}
</span>
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
{issue.title}
</p>
<div className="flex-1 min-w-0 ml-0.5">
<p className="typography-small text-foreground truncate">
{issue.title}
</p>
{issue.sourceRepo?.source === 'upstream' ? (
<span className="typography-micro px-1 py-0.5 rounded bg-status-info/10 text-status-info mt-0.5 inline-block">
{issue.sourceRepo.owner}/{issue.sourceRepo.repo}
</span>
) : null}
</div>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{startingIssueNumber === issue.number ? (
@@ -23,7 +23,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import type { GitHubPullRequestContextResult, GitHubPullRequestSummary, GitHubPullRequestsListResult } from '@/lib/api/types';
import type { GitHubPullRequestContextResult, GitHubPullRequestSummary, GitHubPullRequestsListResult, GitHubRepoSelector } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
const parsePrNumber = (value: string): number | null => {
@@ -195,7 +195,7 @@ export function GitHubPrPickerDialog({
const directNumber = React.useMemo(() => parsePrNumber(query), [query]);
const attachPr = React.useCallback(async (prNumber: number) => {
const attachPr = React.useCallback(async (prNumber: number, sourceRepo?: GitHubRepoSelector | null) => {
if (!projectDirectory) {
toast.error(t('session.githubPrPicker.error.noActiveProject'));
return;
@@ -211,6 +211,7 @@ export function GitHubPrPickerDialog({
const context = await github.prContext(projectDirectory, prNumber, {
includeDiff,
includeCheckDetails: false,
sourceRepo,
});
if (context.connected === false) {
@@ -348,18 +349,23 @@ export function GitHubPrPickerDialog({
{filtered.map((pr) => (
<div
key={pr.number}
key={`${pr.sourceRepo?.owner ?? ''}-${pr.sourceRepo?.repo ?? ''}-${pr.number}`}
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
loadingPrNumber === pr.number && 'bg-interactive-selection/30'
)}
onClick={() => void attachPr(pr.number)}
onClick={() => void attachPr(pr.number, pr.sourceRepo)}
>
<div className="flex-1 min-w-0 ml-0.5">
<p className="typography-small text-foreground truncate">
<span className="text-muted-foreground mr-1">#{pr.number}</span>
{pr.title}
</p>
{pr.sourceRepo?.source === 'upstream' ? (
<span className="typography-micro px-1 py-0.5 rounded bg-status-info/10 text-status-info">
{pr.sourceRepo.owner}/{pr.sourceRepo.repo}
</span>
) : null}
<p className="typography-meta text-muted-foreground truncate">{pr.head} {pr.base}</p>
</div>
@@ -137,6 +137,8 @@ const isKnownActiveSessionDirectory = (session: Session, knownDirectories: Set<s
return knownDirectories.has(directory);
};
const SIDEBAR_PR_NO_PR_RETRY_MS = 5 * 60_000;
interface SessionSidebarProps {
mobileVariant?: boolean;
onSessionSelected?: (sessionId: string) => void;
@@ -157,6 +159,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const [sessionSearchQuery, setSessionSearchQuery] = React.useState('');
const sessionSearchContainerRef = React.useRef<HTMLDivElement | null>(null);
const sessionSearchInputRef = React.useRef<HTMLInputElement | null>(null);
const retriedNoPrStatusKeysRef = React.useRef<Set<string>>(new Set());
const [editingId, setEditingId] = React.useState<string | null>(null);
const [editTitle, setEditTitle] = React.useState('');
const [editingProjectDialogId, setEditingProjectDialogId] = React.useState<string | null>(null);
@@ -1123,6 +1126,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}
const missingTargets: Array<{ directory: string; branch: string; remoteName?: string | null }> = [];
const now = Date.now();
sectionsForSidebarRender.forEach((section) => {
if (collapsedProjects.has(section.project.id)) {
@@ -1137,7 +1141,22 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
}
const key = getGitHubPrStatusKey(directory, branch);
const entry = useGitHubPrStatusStore.getState().entries[key];
if (!entry || !entry.isInitialStatusResolved) {
const hasPr = Boolean(entry?.status?.pr);
const retryKey = `${directory}::${branch}`;
const noPrLastCheckedAt = Math.max(entry?.lastRefreshAt ?? 0, entry?.lastDiscoveryPollAt ?? 0);
const shouldRetryNoPr = Boolean(
entry?.isInitialStatusResolved
&& !hasPr
&& (
!retriedNoPrStatusKeysRef.current.has(retryKey)
|| now - noPrLastCheckedAt >= SIDEBAR_PR_NO_PR_RETRY_MS
),
);
if (!entry || !entry.isInitialStatusResolved || shouldRetryNoPr) {
if (shouldRetryNoPr) {
retriedNoPrStatusKeysRef.current.add(retryKey);
}
missingTargets.push({ directory, branch });
}
});
+7 -7
View File
@@ -36,17 +36,17 @@ export const Checkbox = React.memo<CheckboxProps>(function Checkbox({
indeterminate={indeterminate}
aria-label={ariaLabel}
className={cn(
// AlignUI-style rounded box, no explicit border (rely on inset shadow for unchecked)
'group/checkbox relative flex shrink-0 self-center items-center justify-center rounded-[4px] outline-none',
// AlignUI-style rounded box. Use a real border so press/hover states never lose the outline.
'group/checkbox relative flex shrink-0 self-center items-center justify-center rounded-[4px] border outline-none',
boxSize,
'transition-[background-color,box-shadow] duration-200 ease-out',
'transition-[background-color,border-color,box-shadow] duration-200 ease-out',
// Drive fill directly from React props so the initial paint matches
// the final state without waiting for Base UI to hydrate data attrs.
isOn
? 'bg-transparent shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--primary-base)_50%,transparent)] hover:bg-[var(--interactive-hover)]'
: 'bg-[var(--surface-muted)] shadow-[inset_0_0_0_1px_var(--interactive-border)] hover:bg-[var(--interactive-hover)]',
// focus
'focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-background',
? 'border-[color:color-mix(in_srgb,var(--primary-base)_65%,var(--interactive-border))] bg-transparent shadow-none hover:bg-[var(--interactive-hover)] hover:border-[color:color-mix(in_srgb,var(--primary-base)_75%,var(--interactive-border))]'
: 'border-[var(--interactive-border)] bg-transparent shadow-none hover:bg-[var(--interactive-hover)] hover:border-[var(--interactive-border)]',
// focus: transparent offset so parent bg (e.g. sidebar) doesn't create a visible gap
'focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-transparent',
// disabled
'disabled:cursor-not-allowed disabled:opacity-50',
className,
+1 -1
View File
@@ -1381,7 +1381,7 @@ export const GitView: React.FC = () => {
worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits
);
const canShowPullRequestSection = Boolean(
currentDirectory && currentBranch && status?.tracking && currentBranch !== baseBranch
currentDirectory && currentBranch
);
const canShowBranchWorkflows = Boolean(currentBranch);
const integrateCommitsProps =
@@ -16,6 +16,7 @@ import {
RiGitPullRequestLine,
RiInformationLine,
RiLoader4Line,
RiRefreshLine,
} from '@remixicon/react';
import { toast } from '@/components/ui';
import { Checkbox } from '@/components/ui/checkbox';
@@ -59,6 +60,7 @@ import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHub
import type {
GitHubPullRequest,
GitHubCheckRun,
GitHubAPI,
GitHubPullRequestContextResult,
GitHubPullRequestStatus,
GitRemote,
@@ -66,6 +68,7 @@ import type {
import { useI18n } from '@/lib/i18n';
type MergeMethod = 'merge' | 'squash' | 'rebase';
type DetectedUpstream = { owner: string; repo: string; url: string; defaultBranch?: string; defaultBranchSha?: string | null; remoteName?: string | null };
const statusColor = (state: string | undefined | null): string => {
switch (state) {
@@ -270,6 +273,56 @@ const pullRequestDraftSnapshots = new Map<string, PullRequestDraftSnapshot>();
const openExternal = openExternalUrl;
function useDetectedUpstreamRepo(directory: string, github: GitHubAPI | undefined) {
const [detectedUpstream, setDetectedUpstream] = React.useState<DetectedUpstream | null>(null);
const [upstreamBranches, setUpstreamBranches] = React.useState<string[]>([]);
const attemptedDirectoryRef = React.useRef<string | null>(null);
React.useEffect(() => {
setDetectedUpstream(null);
setUpstreamBranches([]);
}, [directory]);
React.useEffect(() => {
if (!directory || !github?.repoUpstream || attemptedDirectoryRef.current === directory) {
return;
}
attemptedDirectoryRef.current = directory;
let cancelled = false;
void (async () => {
try {
const result = await github.repoUpstream(directory);
if (cancelled || !result?.isFork || !result.upstream) {
return;
}
setDetectedUpstream(result.upstream);
if (!github.repoBranches) {
return;
}
try {
const branches = await github.repoBranches(result.upstream.owner, result.upstream.repo);
if (!cancelled) {
setUpstreamBranches(branches);
}
} catch {
// Silently fail - branch list is best-effort.
}
} catch {
// Silently fail - upstream detection is best-effort.
}
})();
return () => {
cancelled = true;
};
}, [directory, github]);
return { detectedUpstream, upstreamBranches };
}
export const PullRequestSection: React.FC<{
directory: string;
branch: string;
@@ -339,6 +392,16 @@ export const PullRequestSection: React.FC<{
trackingBranch,
})
);
const [useDetectedUpstream, setUseDetectedUpstream] = React.useState(false);
const { detectedUpstream, upstreamBranches } = useDetectedUpstreamRepo(directory, github);
React.useEffect(() => {
setUseDetectedUpstream(false);
}, [directory]);
const hasUpstreamRemote = remotes.some((r) => r.name === 'upstream');
const isFork = hasUpstreamRemote || detectedUpstream !== null;
const canShow = Boolean(directory && branch && baseBranch && (branch !== baseBranch || isFork));
const prStatusKey = React.useMemo(
() => getGitHubPrStatusKey(directory, branch),
@@ -352,7 +415,7 @@ export const PullRequestSection: React.FC<{
const isInitialStatusResolved = statusEntry?.isInitialStatusResolved ?? false;
const availableBaseBranches = React.useMemo(() => {
const selectedRemoteName = selectedRemote?.name?.trim() || null;
const selectedRemoteName = useDetectedUpstream ? null : (selectedRemote?.name?.trim() || null);
const unique = new Set<string>();
for (const remoteBranch of remoteBranches) {
@@ -363,6 +426,15 @@ export const PullRequestSection: React.FC<{
unique.add(branchName);
}
// When using detected upstream, include all upstream repo branches
if (useDetectedUpstream) {
for (const b of upstreamBranches) {
if (b && b !== 'HEAD') {
unique.add(b);
}
}
}
const defaultBase = normalizeBranchRef(baseBranch);
if (defaultBase && defaultBase !== 'HEAD') {
unique.add(defaultBase);
@@ -374,7 +446,7 @@ export const PullRequestSection: React.FC<{
}
return Array.from(unique).sort((a, b) => a.localeCompare(b));
}, [baseBranch, remoteBranches, selectedRemote?.name, targetBaseBranch]);
}, [baseBranch, remoteBranches, selectedRemote?.name, targetBaseBranch, upstreamBranches, useDetectedUpstream]);
const hasMultipleRemotes = remotes.length > 1;
@@ -432,7 +504,19 @@ export const PullRequestSection: React.FC<{
const autoRemoteProbeDoneRef = React.useRef<Set<string>>(new Set());
const pendingActionRefreshTimersRef = React.useRef<number[]>([]);
const canShow = Boolean(directory && branch && baseBranch && branch !== baseBranch);
// Auto-enable detected upstream when there's no explicit upstream remote
React.useEffect(() => {
if (detectedUpstream && !hasUpstreamRemote) {
setUseDetectedUpstream(true);
}
}, [detectedUpstream, hasUpstreamRemote]);
// Set target base branch to upstream's default branch when using detected upstream
React.useEffect(() => {
if (useDetectedUpstream && detectedUpstream?.defaultBranch) {
setTargetBaseBranch(detectedUpstream.defaultBranch);
}
}, [useDetectedUpstream, detectedUpstream?.defaultBranch]);
const pr = status?.pr ?? null;
const currentPrBodyHydrationKey = pr ? `${directory}#${pr.number}` : null;
@@ -1120,8 +1204,14 @@ export const PullRequestSection: React.FC<{
if (!directory) return;
setIsGenerating(true);
try {
// For cross-repo PRs, use the upstream's default branch SHA for the commit range.
// Using a bare branch name like "main" would resolve to the local ref, making
// "git log main..main" a no-op. The SHA points to the actual upstream commit.
const baseRef = (useDetectedUpstream && detectedUpstream?.defaultBranchSha)
? detectedUpstream.defaultBranchSha
: targetBaseBranch;
const payload: { base: string; head: string; context?: string; files?: string[] } = {
base: targetBaseBranch,
base: baseRef,
head: branch,
};
if (additionalContext) {
@@ -1142,7 +1232,7 @@ export const PullRequestSection: React.FC<{
} finally {
setIsGenerating(false);
}
}, [additionalContext, branch, directory, isGenerating, onGeneratedDescription, targetBaseBranch, t]);
}, [additionalContext, branch, detectedUpstream?.defaultBranchSha, directory, isGenerating, onGeneratedDescription, targetBaseBranch, t, useDetectedUpstream]);
const createPr = React.useCallback(async () => {
if (!github?.prCreate) {
@@ -1160,15 +1250,17 @@ export const PullRequestSection: React.FC<{
toast.error(t('gitView.pr.toast.baseBranchRequired'));
return;
}
if (trimmedBase === branch) {
if (!useDetectedUpstream && trimmedBase === branch) {
toast.error(t('gitView.pr.toast.baseMustDifferFromHead'));
return;
}
setIsCreating(true);
try {
// Let the server determine the head source from tracking info
// The server will check the branch's tracking remote and use that
const trackingRemoteName = getTrackingRemoteName(trackingBranch);
const usingDetectedUpstream = useDetectedUpstream && detectedUpstream;
const pr = await github.prCreate({
directory,
title: trimmedTitle,
@@ -1176,7 +1268,14 @@ export const PullRequestSection: React.FC<{
base: trimmedBase,
...(body.trim() ? { body } : {}),
draft,
...(selectedRemote ? { remote: selectedRemote.name } : {}),
...(usingDetectedUpstream
? { targetRepo: { owner: detectedUpstream.owner, repo: detectedUpstream.repo }, headRemote: 'origin' }
: {
...(selectedRemote ? { remote: selectedRemote.name } : {}),
...(trackingRemoteName && trackingRemoteName !== selectedRemote?.name
? { headRemote: trackingRemoteName }
: {}),
}),
});
toast.success(t('gitView.pr.toast.prCreated'));
updatePrStatus(prStatusKey, (prev) => (prev ? { ...prev, pr } : prev));
@@ -1188,7 +1287,7 @@ export const PullRequestSection: React.FC<{
} finally {
setIsCreating(false);
}
}, [body, branch, directory, draft, github, prStatusKey, refresh, scheduleActionRefresh, selectedRemote, targetBaseBranch, title, updatePrStatus, t]);
}, [body, branch, detectedUpstream, directory, draft, github, prStatusKey, refresh, scheduleActionRefresh, selectedRemote, targetBaseBranch, title, trackingBranch, updatePrStatus, useDetectedUpstream, t]);
const mergePr = React.useCallback(async (pr: GitHubPullRequest) => {
if (!github?.prMerge) {
@@ -1283,7 +1382,8 @@ export const PullRequestSection: React.FC<{
return null;
}
const repoUrl = status?.repo?.url || null;
const originRepoUrl = status?.repo?.url || null;
const repoUrl = (useDetectedUpstream && detectedUpstream?.url) ? detectedUpstream.url : originRepoUrl;
const checks = status?.checks ?? null;
const canMerge = Boolean(status?.canMerge);
const isConnected = Boolean(status?.connected);
@@ -1331,17 +1431,40 @@ export const PullRequestSection: React.FC<{
</div>
<div className="flex items-center gap-2">
{isLoading ? <RiLoader4Line className="size-4 animate-spin text-muted-foreground" /> : null}
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex size-5 items-center justify-center rounded hover:bg-interactive-hover/60 disabled:opacity-40"
disabled={isLoading}
onClick={() => void refresh({ force: true })}
aria-label={t('gitView.pr.actions.refreshAria')}
>
<RiRefreshLine className="size-3.5 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent><p>{t('gitView.pr.actions.refresh')}</p></TooltipContent>
</Tooltip>
{checks ? (
<span className="inline-flex items-center gap-2 typography-micro text-muted-foreground">
<span className={`h-2 w-2 rounded-full ${statusColor(checks.state)}`} />
{checks.total > 0 ? `${checks.success}/${checks.total} checks` : `${checks.state} checks`}
</span>
) : null}
{hasMultipleRemotes ? (
{trackingBranch && selectedRemote && trackingBranch.split('/')[0] !== selectedRemote.name ? (
<span className="typography-micro text-muted-foreground">
{trackingBranch.split('/')[0]} {selectedRemote.name}
</span>
) : null}
{hasMultipleRemotes || detectedUpstream ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="xs" className="gap-1">
<span className="typography-micro">{selectedRemote?.name}</span>
<span className="typography-micro">
{useDetectedUpstream && detectedUpstream
? `upstream · ${detectedUpstream.owner}/${detectedUpstream.repo}`
: selectedRemote?.name ?? 'target'}
</span>
<RiArrowDownSLine className="size-3" />
</Button>
</DropdownMenuTrigger>
@@ -1349,12 +1472,15 @@ export const PullRequestSection: React.FC<{
{remotes.map((remote) => (
<DropdownMenuItem
key={remote.name}
onSelect={() => handleRemoteChange(remote)}
onSelect={() => {
setUseDetectedUpstream(false);
handleRemoteChange(remote);
}}
>
<div className="flex flex-col">
<span className="typography-ui-label text-foreground">
{remote.name}
{remote.name === selectedRemote?.name && (
{!useDetectedUpstream && remote.name === selectedRemote?.name && (
<span className="ml-2 text-primary"></span>
)}
</span>
@@ -1364,6 +1490,24 @@ export const PullRequestSection: React.FC<{
</div>
</DropdownMenuItem>
))}
{detectedUpstream ? (
<DropdownMenuItem
key="detected-upstream"
onSelect={() => setUseDetectedUpstream(true)}
>
<div className="flex flex-col">
<span className="typography-ui-label text-foreground">
upstream · {detectedUpstream.owner}/{detectedUpstream.repo}
{useDetectedUpstream && (
<span className="ml-2 text-primary"></span>
)}
</span>
<span className="typography-meta text-muted-foreground truncate">
{detectedUpstream.url}
</span>
</div>
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
@@ -1647,7 +1791,7 @@ export const PullRequestSection: React.FC<{
<div className="min-w-0">
<div className="typography-ui-label text-foreground">{t('gitView.pr.createTitle')}</div>
<div className="typography-micro text-muted-foreground truncate">
{branch} {targetBaseBranch}
{branch} <span className="opacity-60">(local)</span> {targetBaseBranch} <span className="opacity-60">({useDetectedUpstream && detectedUpstream ? 'upstream' : 'remote'})</span>
</div>
</div>
{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)}
>
<span className="inline-flex size-4 items-center justify-center">
{isCreating ? <RiLoader4Line className="size-4 animate-spin" /> : <RiGitPullRequestLine className="size-4" />}
@@ -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,
};
}
+21 -4
View File
@@ -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<GitHubAuthStatus>;
me?(): Promise<GitHubUserSummary>;
prStatus(directory: string, branch: string, remote?: string): Promise<GitHubPullRequestStatus>;
prStatus(directory: string, branch: string, remote?: string, options?: { force?: boolean }): Promise<GitHubPullRequestStatus>;
prCreate(payload: GitHubPullRequestCreateInput): Promise<GitHubPullRequest>;
prUpdate(payload: GitHubPullRequestUpdateInput): Promise<GitHubPullRequest>;
prMerge(payload: GitHubPullRequestMergeInput): Promise<GitHubPullRequestMergeResult>;
@@ -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<GitHubPullRequestContextResult>;
issuesList(directory: string, options?: { page?: number }): Promise<GitHubIssuesListResult>;
issueGet(directory: string, number: number): Promise<GitHubIssueGetResult>;
issueComments(directory: string, number: number): Promise<GitHubIssueCommentsResult>;
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>;
repoBranches(owner: string, repo: string): Promise<string[]>;
}
export interface RuntimeAPIs {
+4
View File
@@ -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',
+4
View File
@@ -3,6 +3,8 @@ import { settingsDict } from './es.settings';
export const dict: Record<I18nKey, string> = {
...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<I18nKey, string> = {
"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",
+4
View File
@@ -3,6 +3,8 @@ import { settingsDict } from './ko.settings';
export const dict: Record<I18nKey, string> = {
...settingsDict,
'common.loading': '로딩 중...',
'common.unavailable': '사용할 수 없음',
'common.language.english': '영어',
'common.language.simplifiedChinese': '중국어(간체)',
'common.language.ukrainian': '우크라이나어',
@@ -567,6 +569,8 @@ export const dict: Record<I18nKey, string> = {
'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': '선택 사항',
@@ -3,6 +3,8 @@ import { settingsDict } from './pt-BR.settings';
export const dict: Record<I18nKey, string> = {
...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<I18nKey, string> = {
"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",
+4
View File
@@ -3,6 +3,8 @@ import { settingsDict } from './uk.settings';
export const dict: Record<I18nKey, string> = {
...settingsDict,
"common.loading": "Завантаження...",
"common.unavailable": "Недоступно",
"common.language.english": "англійська",
"common.language.simplifiedChinese": "Китайська (спрощена)",
"common.language.ukrainian": "Українська",
@@ -567,6 +569,8 @@ export const dict: Record<I18nKey, string> = {
"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": "Додатково",
@@ -3,6 +3,8 @@ import { settingsDict } from './zh-CN.settings';
export const dict: Record<I18nKey, string> = {
...settingsDict,
'common.loading': '加载中...',
'common.unavailable': '不可用',
'common.language.english': 'English',
'common.language.simplifiedChinese': '简体中文',
'common.language.ukrainian': '乌克兰语',
@@ -567,6 +569,8 @@ export const dict: Record<I18nKey, string> = {
'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': '可选',
@@ -486,7 +486,7 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
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) => {
+3 -1
View File
@@ -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 };
}
+12 -5
View File
@@ -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<GitHubIssuesListResult>('api:github/issues:list', { directory, page: options?.page ?? 1 }),
issueGet: async (directory: string, number: number) =>
sendBridgeMessage<GitHubIssueGetResult>('api:github/issues:get', { directory, number }),
issueComments: async (directory: string, number: number) =>
sendBridgeMessage<GitHubIssueCommentsResult>('api:github/issues:comments', { directory, number }),
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 }),
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<GitHubPullRequestContextResult>('api:github/pulls:context', {
directory,
number,
includeDiff: Boolean(options?.includeDiff),
includeCheckDetails: Boolean(options?.includeCheckDetails),
sourceRepo: options?.sourceRepo ?? null,
}),
repoUpstream: async (directory: string) =>
sendBridgeMessage<GitHubRepoUpstreamResult>('api:github/repo:upstream', { directory }),
repoBranches: async (owner: string, repo: string) =>
sendBridgeMessage<string[]>('api:github/repo:branches', { owner, repo }),
});
+19 -12
View File
@@ -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);
+51 -29
View File
@@ -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 {
@@ -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<Array<{ owner: string, repo: string, url: string, source: string }> | 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;
}
+299 -88
View File
@@ -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 });
}
+50 -12
View File
@@ -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<GitHubPullRequestStatus> {
async prStatus(directory: string, branch: string, remote?: string, options?: { force?: boolean }): Promise<GitHubPullRequestStatus> {
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<GitHubRepoUpstreamResult> {
const response = await fetch(
`/api/github/repo/upstream?directory=${encodeURIComponent(directory)}`,
{ method: 'GET', headers: { Accept: 'application/json' } }
);
const body = await jsonOrNull<GitHubRepoUpstreamResult & { error?: string }>(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<string[]> {
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<GitHubPullRequestsListResult> {
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<GitHubPullRequestContextResult> {
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<GitHubPullRequestContextResult & { error?: string }>(response);
if (!response.ok || !body) {
@@ -207,11 +237,15 @@ export const createWebGitHubAPI = (): GitHubAPI => ({
return payload;
},
async issueGet(directory: string, number: number): Promise<GitHubIssueGetResult> {
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<GitHubIssueGetResult> {
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<GitHubIssueGetResult & { error?: string }>(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<GitHubIssueCommentsResult> {
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<GitHubIssueCommentsResult> {
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<GitHubIssueCommentsResult & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load issue comments');