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
@@ -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" />}
|
||||
|
||||
Reference in New Issue
Block a user