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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
17650becc0
commit
21253d7fc2
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user