* types: add optional context parameter to generatePullRequestDescription signature * api: update gitApi to pass optional context to PR description generation * http: include trimmed context in PR description API request body * ui: add additional context input for PR generation with desktop disclosure and mobile sheet * desktop: pass optional context to Tauri generate_pr_description command * tauri: add optional context parameter and inject into PR description prompt * server: read optional context from request and inject into PR description prompt * vscode: update type signature for context parameter (compatibility only) * fix(vscode): drop context from pr-description payload Remove context field from PR description payload Send only base and head to the bridge API for PR descriptions Clarify payload compatibility across web/desktop environments
890 lines
34 KiB
TypeScript
890 lines
34 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
RiAiGenerate2,
|
|
RiCheckboxBlankLine,
|
|
RiCheckboxLine,
|
|
RiExternalLinkLine,
|
|
RiGitPullRequestLine,
|
|
RiLoader4Line,
|
|
} from '@remixicon/react';
|
|
import { toast } from '@/components/ui';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from '@/components/ui/collapsible';
|
|
import { generatePullRequestDescription } from '@/lib/gitApi';
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
import { useDeviceInfo } from '@/lib/device';
|
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useMessageStore } from '@/stores/messageStore';
|
|
import { useSessionStore } from '@/stores/useSessionStore';
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
|
import type {
|
|
GitHubPullRequest,
|
|
GitHubCheckRun,
|
|
GitHubPullRequestContextResult,
|
|
GitHubPullRequestStatus,
|
|
} from '@/lib/api/types';
|
|
|
|
type MergeMethod = 'merge' | 'squash' | 'rebase';
|
|
|
|
const statusColor = (state: string | undefined | null): string => {
|
|
switch (state) {
|
|
case 'success':
|
|
return 'bg-[color:var(--status-success)]';
|
|
case 'failure':
|
|
return 'bg-[color:var(--status-error)]';
|
|
case 'pending':
|
|
return 'bg-[color:var(--status-warning)]';
|
|
default:
|
|
return 'bg-muted-foreground/40';
|
|
}
|
|
};
|
|
|
|
const branchToTitle = (branch: string): string => {
|
|
return branch
|
|
.replace(/^refs\/heads\//, '')
|
|
.replace(/[-_]+/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
};
|
|
|
|
type PullRequestDraftSnapshot = {
|
|
title: string;
|
|
body: string;
|
|
draft: boolean;
|
|
isOpen: boolean;
|
|
additionalContext: string;
|
|
};
|
|
|
|
const pullRequestDraftSnapshots = new Map<string, PullRequestDraftSnapshot>();
|
|
|
|
const openExternal = async (url: string) => {
|
|
if (typeof window === 'undefined') return;
|
|
const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop;
|
|
if (desktop?.openExternal) {
|
|
try {
|
|
const result = await desktop.openExternal(url);
|
|
if (result && typeof result === 'object' && 'success' in result && (result as { success?: boolean }).success === true) {
|
|
return;
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
try {
|
|
window.open(url, '_blank', 'noopener,noreferrer');
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
export const PullRequestSection: React.FC<{
|
|
directory: string;
|
|
branch: string;
|
|
baseBranch: string;
|
|
}> = ({ directory, branch, baseBranch }) => {
|
|
const { github } = useRuntimeAPIs();
|
|
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
|
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
|
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
|
const setSidebarSection = useUIStore((state) => state.setSidebarSection);
|
|
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
|
const { isMobile } = useDeviceInfo();
|
|
|
|
const openGitHubSettings = React.useCallback(() => {
|
|
setSidebarSection('settings');
|
|
setSettingsDialogOpen(true);
|
|
}, [setSettingsDialogOpen, setSidebarSection]);
|
|
|
|
const snapshotKey = React.useMemo(() => `${directory}::${branch}`, [directory, branch]);
|
|
const initialSnapshot = React.useMemo(
|
|
() => pullRequestDraftSnapshots.get(snapshotKey) ?? null,
|
|
[snapshotKey]
|
|
);
|
|
|
|
const [isOpen, setIsOpen] = React.useState(initialSnapshot?.isOpen ?? true);
|
|
const [isLoading, setIsLoading] = React.useState(false);
|
|
const [status, setStatus] = React.useState<GitHubPullRequestStatus | null>(null);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
|
|
const [title, setTitle] = React.useState(() => initialSnapshot?.title ?? branchToTitle(branch));
|
|
const [body, setBody] = React.useState(() => initialSnapshot?.body ?? '');
|
|
const [draft, setDraft] = React.useState(() => initialSnapshot?.draft ?? false);
|
|
const [additionalContext, setAdditionalContext] = React.useState(() => initialSnapshot?.additionalContext ?? '');
|
|
const [mergeMethod, setMergeMethod] = React.useState<MergeMethod>('squash');
|
|
|
|
const [isGenerating, setIsGenerating] = React.useState(false);
|
|
const [isCreating, setIsCreating] = React.useState(false);
|
|
const [isMerging, setIsMerging] = React.useState(false);
|
|
const [isMarkingReady, setIsMarkingReady] = React.useState(false);
|
|
|
|
const [isContextOpen, setIsContextOpen] = React.useState(false);
|
|
const [isContextSheetOpen, setIsContextSheetOpen] = React.useState(false);
|
|
|
|
const [checksDialogOpen, setChecksDialogOpen] = React.useState(false);
|
|
const [checkDetails, setCheckDetails] = React.useState<GitHubPullRequestContextResult | null>(null);
|
|
const [isLoadingCheckDetails, setIsLoadingCheckDetails] = React.useState(false);
|
|
|
|
const canShow = Boolean(directory && branch && baseBranch && branch !== baseBranch);
|
|
|
|
const pr = status?.pr ?? null;
|
|
|
|
const openChecksDialog = React.useCallback(async () => {
|
|
if (!github?.prContext) {
|
|
toast.error('GitHub runtime API unavailable');
|
|
return;
|
|
}
|
|
if (!pr) return;
|
|
|
|
setChecksDialogOpen(true);
|
|
setIsLoadingCheckDetails(true);
|
|
try {
|
|
const ctx = await github.prContext(directory, pr.number, {
|
|
includeDiff: false,
|
|
includeCheckDetails: true,
|
|
});
|
|
setCheckDetails(ctx);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error('Failed to load check details', { description: message });
|
|
} finally {
|
|
setIsLoadingCheckDetails(false);
|
|
}
|
|
}, [directory, github, pr]);
|
|
|
|
const renderCheckRunSummary = React.useCallback((run: GitHubCheckRun) => {
|
|
const status = run.status || 'unknown';
|
|
const conclusion = run.conclusion ?? undefined;
|
|
const statusText = conclusion ? `${status} / ${conclusion}` : status;
|
|
const appName = run.app?.name || run.app?.slug;
|
|
return (
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="typography-ui-label text-foreground truncate">{run.name}</div>
|
|
<div className="typography-micro text-muted-foreground truncate">
|
|
{appName ? `${appName} · ${statusText}` : statusText}
|
|
</div>
|
|
{run.output?.summary ? (
|
|
<div className="typography-micro text-muted-foreground whitespace-pre-wrap line-clamp-4 mt-2">
|
|
{run.output.summary}
|
|
</div>
|
|
) : null}
|
|
{run.job?.steps && run.job.steps.length > 0 ? (
|
|
<div className="mt-2 space-y-1">
|
|
<div className="typography-micro text-muted-foreground">Steps</div>
|
|
<div className="space-y-1">
|
|
{run.job.steps.map((step, idx) => {
|
|
const c = (step.conclusion || '').toLowerCase();
|
|
const isFail = c && !['success', 'neutral', 'skipped'].includes(c);
|
|
return (
|
|
<div
|
|
key={`${step.name}-${idx}`}
|
|
className={
|
|
'typography-micro flex items-center gap-2 rounded px-2 py-1 ' +
|
|
(isFail ? 'bg-destructive/10 text-destructive' : 'text-muted-foreground')
|
|
}
|
|
>
|
|
<span className="truncate">{step.name}</span>
|
|
{step.conclusion ? <span className="ml-auto flex-shrink-0">{step.conclusion}</span> : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{run.detailsUrl ? (
|
|
<Button variant="outline" size="sm" asChild className="flex-shrink-0">
|
|
<a href={run.detailsUrl} target="_blank" rel="noopener noreferrer">
|
|
<RiExternalLinkLine className="size-4" />
|
|
Open
|
|
</a>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}, []);
|
|
|
|
const sendFailedChecksToChat = React.useCallback(async () => {
|
|
setActiveMainTab('chat');
|
|
|
|
if (!github?.prContext) {
|
|
toast.error('GitHub runtime API unavailable');
|
|
return;
|
|
}
|
|
if (!directory || !pr) return;
|
|
if (!currentSessionId) {
|
|
toast.error('No active session', { description: 'Open a chat session first.' });
|
|
return;
|
|
}
|
|
|
|
const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState();
|
|
const lastUsedProvider = useMessageStore.getState().lastUsedProvider;
|
|
const providerID = currentProviderId || lastUsedProvider?.providerID;
|
|
const modelID = currentModelId || lastUsedProvider?.modelID;
|
|
if (!providerID || !modelID) {
|
|
toast.error('No model selected');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const context = await github.prContext(directory, pr.number, { includeDiff: false, includeCheckDetails: true });
|
|
const runs = context.checkRuns ?? [];
|
|
const failed = runs.filter((r) => {
|
|
const conclusion = typeof r.conclusion === 'string' ? r.conclusion.toLowerCase() : '';
|
|
if (!conclusion) return false;
|
|
return !['success', 'neutral', 'skipped'].includes(conclusion);
|
|
});
|
|
|
|
if (failed.length === 0) {
|
|
toast.message('No failed checks');
|
|
return;
|
|
}
|
|
|
|
const visibleText = 'Review these PR failed checks and propose likely fixes. Do not implement until I confirm.';
|
|
const instructionsText = `Use the attached checks payload.
|
|
- Summarize what is failing.
|
|
- Identify likely root cause(s).
|
|
- Propose a minimal fix plan and verification steps.
|
|
- No speculation: ask for missing info if needed.`;
|
|
const payloadText = `GitHub PR failed checks (JSON)\n${JSON.stringify({
|
|
repo: context.repo ?? null,
|
|
pr: context.pr ?? null,
|
|
failedChecks: failed,
|
|
}, null, 2)}`;
|
|
|
|
void useMessageStore.getState().sendMessage(
|
|
visibleText,
|
|
providerID,
|
|
modelID,
|
|
currentAgentName ?? undefined,
|
|
currentSessionId,
|
|
undefined,
|
|
null,
|
|
[
|
|
{ text: instructionsText, synthetic: true },
|
|
{ text: payloadText, synthetic: true },
|
|
],
|
|
currentVariant
|
|
).catch((e) => {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error('Failed to send message', { description: message });
|
|
});
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error('Failed to load checks', { description: message });
|
|
}
|
|
}, [currentSessionId, directory, github, pr, setActiveMainTab]);
|
|
|
|
const sendCommentsToChat = React.useCallback(async () => {
|
|
setActiveMainTab('chat');
|
|
|
|
if (!github?.prContext) {
|
|
toast.error('GitHub runtime API unavailable');
|
|
return;
|
|
}
|
|
if (!directory || !pr) return;
|
|
if (!currentSessionId) {
|
|
toast.error('No active session', { description: 'Open a chat session first.' });
|
|
return;
|
|
}
|
|
|
|
const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState();
|
|
const lastUsedProvider = useMessageStore.getState().lastUsedProvider;
|
|
const providerID = currentProviderId || lastUsedProvider?.providerID;
|
|
const modelID = currentModelId || lastUsedProvider?.modelID;
|
|
if (!providerID || !modelID) {
|
|
toast.error('No model selected');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const context = await github.prContext(directory, pr.number, { includeDiff: false, includeCheckDetails: false });
|
|
const issueComments = context.issueComments ?? [];
|
|
const reviewComments = context.reviewComments ?? [];
|
|
const total = issueComments.length + reviewComments.length;
|
|
if (total === 0) {
|
|
toast.message('No PR comments');
|
|
return;
|
|
}
|
|
|
|
const visibleText = 'Review these PR comments and propose the required changes and next actions. Do not implement until I confirm.';
|
|
const instructionsText = `Use the attached comments payload.
|
|
- Identify required vs optional changes.
|
|
- Call out intent/implementation mismatch if present.
|
|
- Propose a minimal plan and verification steps.
|
|
- No speculation: ask for missing info if needed.`;
|
|
const payloadText = `GitHub PR comments (JSON)\n${JSON.stringify({
|
|
repo: context.repo ?? null,
|
|
pr: context.pr ?? null,
|
|
issueComments,
|
|
reviewComments,
|
|
}, null, 2)}`;
|
|
|
|
void useMessageStore.getState().sendMessage(
|
|
visibleText,
|
|
providerID,
|
|
modelID,
|
|
currentAgentName ?? undefined,
|
|
currentSessionId,
|
|
undefined,
|
|
null,
|
|
[
|
|
{ text: instructionsText, synthetic: true },
|
|
{ text: payloadText, synthetic: true },
|
|
],
|
|
currentVariant
|
|
).catch((e) => {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error('Failed to send message', { description: message });
|
|
});
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error('Failed to load PR comments', { description: message });
|
|
}
|
|
}, [currentSessionId, directory, github, pr, setActiveMainTab]);
|
|
|
|
const refresh = React.useCallback(async () => {
|
|
if (!canShow) return;
|
|
if (githubAuthChecked && githubAuthStatus?.connected === false) {
|
|
setStatus({ connected: false });
|
|
setError(null);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
if (!github?.prStatus) {
|
|
setStatus(null);
|
|
setError('GitHub runtime API unavailable');
|
|
return;
|
|
}
|
|
setIsLoading(true);
|
|
setError(null);
|
|
try {
|
|
const next = await github.prStatus(directory, branch);
|
|
setStatus(next);
|
|
if (next.connected === false) {
|
|
setError(null);
|
|
}
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
setError(message || 'Failed to load PR status');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [branch, canShow, directory, github, githubAuthChecked, githubAuthStatus]);
|
|
|
|
React.useEffect(() => {
|
|
const snapshot = pullRequestDraftSnapshots.get(snapshotKey) ?? null;
|
|
setTitle(snapshot?.title ?? branchToTitle(branch));
|
|
setBody(snapshot?.body ?? '');
|
|
setDraft(snapshot?.draft ?? false);
|
|
setIsOpen(snapshot?.isOpen ?? true);
|
|
void refresh();
|
|
}, [branch, refresh, snapshotKey]);
|
|
|
|
React.useEffect(() => {
|
|
if (githubAuthChecked && githubAuthStatus?.connected === false) {
|
|
setStatus({ connected: false });
|
|
setError(null);
|
|
}
|
|
}, [githubAuthChecked, githubAuthStatus]);
|
|
|
|
React.useEffect(() => {
|
|
if (!directory || !branch) {
|
|
return;
|
|
}
|
|
pullRequestDraftSnapshots.set(snapshotKey, {
|
|
title,
|
|
body,
|
|
draft,
|
|
isOpen,
|
|
additionalContext,
|
|
});
|
|
}, [snapshotKey, title, body, draft, isOpen, additionalContext, directory, branch]);
|
|
|
|
const generateDescription = React.useCallback(async () => {
|
|
if (isGenerating) return;
|
|
if (!directory) return;
|
|
setIsGenerating(true);
|
|
try {
|
|
const generated = await generatePullRequestDescription(directory, {
|
|
base: baseBranch,
|
|
head: branch,
|
|
context: additionalContext,
|
|
});
|
|
|
|
if (generated.title?.trim()) {
|
|
setTitle(generated.title.trim());
|
|
}
|
|
if (generated.body?.trim()) {
|
|
setBody(generated.body.trim());
|
|
}
|
|
toast.success('PR description generated');
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error('Failed to generate description', { description: message });
|
|
} finally {
|
|
setIsGenerating(false);
|
|
}
|
|
}, [baseBranch, branch, directory, isGenerating, additionalContext]);
|
|
|
|
const createPr = React.useCallback(async () => {
|
|
if (!github?.prCreate) {
|
|
toast.error('GitHub runtime API unavailable');
|
|
return;
|
|
}
|
|
const trimmedTitle = title.trim();
|
|
if (!trimmedTitle) {
|
|
toast.error('Title is required');
|
|
return;
|
|
}
|
|
|
|
setIsCreating(true);
|
|
try {
|
|
const pr = await github.prCreate({
|
|
directory,
|
|
title: trimmedTitle,
|
|
head: branch,
|
|
base: baseBranch,
|
|
...(body.trim() ? { body } : {}),
|
|
draft,
|
|
});
|
|
toast.success('PR created');
|
|
setStatus((prev) => (prev ? { ...prev, pr } : prev));
|
|
await refresh();
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error('Failed to create PR', { description: message });
|
|
} finally {
|
|
setIsCreating(false);
|
|
}
|
|
}, [baseBranch, body, branch, directory, draft, github, refresh, title]);
|
|
|
|
const mergePr = React.useCallback(async (pr: GitHubPullRequest) => {
|
|
if (!github?.prMerge) {
|
|
toast.error('GitHub runtime API unavailable');
|
|
return;
|
|
}
|
|
setIsMerging(true);
|
|
try {
|
|
const result = await github.prMerge({ directory, number: pr.number, method: mergeMethod });
|
|
if (result.merged) {
|
|
toast.success('PR merged');
|
|
} else {
|
|
toast.message('PR not merged', { description: result.message || 'Not mergeable' });
|
|
}
|
|
await refresh();
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error('Merge failed', { description: message });
|
|
if (pr.url) {
|
|
void openExternal(pr.url);
|
|
}
|
|
} finally {
|
|
setIsMerging(false);
|
|
}
|
|
}, [directory, github, mergeMethod, refresh]);
|
|
|
|
const markReady = React.useCallback(async (pr: GitHubPullRequest) => {
|
|
if (!github?.prReady) {
|
|
toast.error('GitHub runtime API unavailable');
|
|
return;
|
|
}
|
|
setIsMarkingReady(true);
|
|
try {
|
|
await github.prReady({ directory, number: pr.number });
|
|
toast.success('Marked ready for review');
|
|
await refresh();
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error('Failed to mark ready', { description: message });
|
|
if (pr.url) {
|
|
void openExternal(pr.url);
|
|
}
|
|
} finally {
|
|
setIsMarkingReady(false);
|
|
}
|
|
}, [directory, github, refresh]);
|
|
|
|
if (!canShow) {
|
|
return null;
|
|
}
|
|
|
|
const repoUrl = status?.repo?.url || null;
|
|
const checks = status?.checks ?? null;
|
|
const canMerge = Boolean(status?.canMerge);
|
|
const isConnected = Boolean(status?.connected);
|
|
const shouldShowConnectionNotice = githubAuthChecked && status?.connected === false;
|
|
|
|
return (
|
|
<Collapsible
|
|
open={isOpen}
|
|
onOpenChange={setIsOpen}
|
|
className="rounded-xl border border-border/60 bg-background/70 overflow-hidden"
|
|
>
|
|
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 h-10 hover:bg-transparent">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<RiGitPullRequestLine className="size-4 text-muted-foreground" />
|
|
<h3 className="typography-ui-header font-semibold text-foreground truncate">Pull Request</h3>
|
|
{pr ? (
|
|
<span className="typography-meta text-muted-foreground truncate">#{pr.number}</span>
|
|
) : null}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{isLoading ? <RiLoader4Line className="size-4 animate-spin text-muted-foreground" /> : null}
|
|
{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}
|
|
</div>
|
|
</CollapsibleTrigger>
|
|
|
|
<CollapsibleContent>
|
|
<div className="border-t border-border/40">
|
|
<div className="flex flex-col gap-3 p-3">
|
|
{shouldShowConnectionNotice ? (
|
|
<div className="space-y-2">
|
|
<div className="typography-meta text-muted-foreground">
|
|
GitHub not connected. Connect your GitHub account in settings.
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={openGitHubSettings} className="w-fit">
|
|
Open settings
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<div className="space-y-2">
|
|
<div className="typography-ui-label text-foreground">PR status unavailable</div>
|
|
<div className="typography-meta text-muted-foreground break-words">{error}</div>
|
|
{repoUrl ? (
|
|
<Button variant="outline" size="sm" asChild className="w-fit">
|
|
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
|
|
<RiExternalLinkLine className="size-4" />
|
|
Open Repo
|
|
</a>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{pr ? (
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="typography-ui-label text-foreground truncate">{pr.title}</div>
|
|
<div className="typography-micro text-muted-foreground truncate">
|
|
{pr.state}{pr.draft ? ' (draft)' : ''}
|
|
{pr.mergeable === false ? ' · not mergeable' : ''}
|
|
{typeof pr.mergeableState === 'string' && pr.mergeableState ? ` · ${pr.mergeableState}` : ''}
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
{checks ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={openChecksDialog}
|
|
disabled={isLoadingCheckDetails}
|
|
>
|
|
{isLoadingCheckDetails ? <RiLoader4Line className="size-4 animate-spin" /> : null}
|
|
Check details
|
|
</Button>
|
|
) : null}
|
|
{checks?.failure ? (
|
|
<Button variant="outline" size="sm" onClick={sendFailedChecksToChat}>
|
|
Send failed checks to chat
|
|
</Button>
|
|
) : null}
|
|
<Button variant="outline" size="sm" onClick={sendCommentsToChat}>
|
|
Send PR comments to chat
|
|
</Button>
|
|
</div>
|
|
{canMerge && pr.draft ? (
|
|
<div className="typography-micro text-muted-foreground">
|
|
Draft PRs must be marked ready before merge.
|
|
</div>
|
|
) : null}
|
|
{!canMerge ? (
|
|
<div className="typography-micro text-muted-foreground">No merge permission; use Open in GitHub.</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" asChild>
|
|
<a href={pr.url} target="_blank" rel="noopener noreferrer">
|
|
<RiExternalLinkLine className="size-4" />
|
|
Open
|
|
</a>
|
|
</Button>
|
|
{canMerge && pr.draft && pr.state === 'open' ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => markReady(pr)}
|
|
disabled={isMarkingReady || isMerging}
|
|
>
|
|
{isMarkingReady ? <RiLoader4Line className="size-4 animate-spin" /> : null}
|
|
Ready
|
|
</Button>
|
|
) : null}
|
|
{canMerge ? (
|
|
<>
|
|
<select
|
|
className="h-8 rounded-md border border-border bg-background px-2 typography-meta"
|
|
value={mergeMethod}
|
|
onChange={(e) => setMergeMethod(e.target.value as MergeMethod)}
|
|
disabled={isMerging || pr.state !== 'open'}
|
|
>
|
|
<option value="squash">Squash</option>
|
|
<option value="merge">Merge</option>
|
|
<option value="rebase">Rebase</option>
|
|
</select>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => mergePr(pr)}
|
|
disabled={isMerging || isMarkingReady || pr.state !== 'open' || pr.draft}
|
|
>
|
|
{isMerging ? <RiLoader4Line className="size-4 animate-spin" /> : null}
|
|
Merge
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<div className="typography-ui-label text-foreground">Create PR</div>
|
|
<div className="typography-micro text-muted-foreground truncate">
|
|
{branch} → {baseBranch}
|
|
</div>
|
|
</div>
|
|
{repoUrl ? (
|
|
<Button variant="outline" size="sm" asChild>
|
|
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
|
|
<RiExternalLinkLine className="size-4" />
|
|
Repo
|
|
</a>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
|
|
<label className="space-y-1">
|
|
<div className="typography-micro text-muted-foreground">Title</div>
|
|
<Input
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder="PR title"
|
|
/>
|
|
</label>
|
|
|
|
<label className="space-y-1">
|
|
<div className="typography-micro text-muted-foreground">Description</div>
|
|
<Textarea
|
|
value={body}
|
|
onChange={(e) => setBody(e.target.value)}
|
|
className="min-h-[110px] bg-background/80"
|
|
placeholder="What changed and why"
|
|
/>
|
|
</label>
|
|
|
|
<div
|
|
className="flex items-center gap-2 cursor-pointer"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-pressed={draft}
|
|
onClick={() => setDraft((v) => !v)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === ' ' || e.key === 'Enter') {
|
|
e.preventDefault();
|
|
setDraft((v) => !v);
|
|
}
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDraft((v) => !v);
|
|
}}
|
|
aria-label="Toggle draft PR"
|
|
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
>
|
|
{draft ? (
|
|
<RiCheckboxLine className="size-4 text-primary" />
|
|
) : (
|
|
<RiCheckboxBlankLine className="size-4" />
|
|
)}
|
|
</button>
|
|
<span className="typography-ui-label text-foreground select-none">Draft</span>
|
|
</div>
|
|
|
|
{/* Additional Context Section */}
|
|
{isMobile ? (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="typography-micro text-muted-foreground">
|
|
Additional context (optional)
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setIsContextSheetOpen(true)}
|
|
>
|
|
{additionalContext.trim() ? 'Edit' : 'Add'}
|
|
</Button>
|
|
</div>
|
|
{additionalContext.trim() && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="inline-flex items-center rounded-full bg-[var(--interactive-selection)] px-2 py-0.5 text-xs text-[var(--interactive-selection-foreground)]">
|
|
Context added
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<Collapsible open={isContextOpen} onOpenChange={setIsContextOpen}>
|
|
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-3 py-2 hover:bg-[var(--interactive-hover)]">
|
|
<span className="typography-micro text-muted-foreground">
|
|
Additional context (optional)
|
|
</span>
|
|
<span className="typography-micro text-[var(--primary-base)]">
|
|
{isContextOpen ? 'Hide' : additionalContext.trim() ? 'Edit' : 'Add'}
|
|
</span>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="mt-2 space-y-2 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-3">
|
|
<Textarea
|
|
value={additionalContext}
|
|
onChange={(e) => setAdditionalContext(e.target.value)}
|
|
className="min-h-[100px] bg-transparent"
|
|
placeholder="Explain why this change is needed... Mention how to test (commands / steps)... Call out risks / rollout plan..."
|
|
/>
|
|
<p className="typography-micro text-muted-foreground">
|
|
This text is only used to guide PR generation.
|
|
</p>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
)}
|
|
|
|
{/* Mobile Sheet for Context */}
|
|
<MobileOverlayPanel
|
|
open={isContextSheetOpen}
|
|
onClose={() => setIsContextSheetOpen(false)}
|
|
title="Additional context"
|
|
footer={
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setIsContextSheetOpen(false)}
|
|
className="w-full"
|
|
>
|
|
Done
|
|
</Button>
|
|
}
|
|
>
|
|
<div className="space-y-3">
|
|
<Textarea
|
|
value={additionalContext}
|
|
onChange={(e) => setAdditionalContext(e.target.value)}
|
|
className="min-h-[200px] bg-transparent"
|
|
placeholder="Explain why this change is needed... Mention how to test (commands / steps)... Call out risks / rollout plan..."
|
|
autoFocus
|
|
/>
|
|
<p className="typography-micro text-muted-foreground">
|
|
This text is only used to guide PR generation.
|
|
</p>
|
|
</div>
|
|
</MobileOverlayPanel>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={generateDescription}
|
|
disabled={isGenerating || isCreating}
|
|
>
|
|
{isGenerating ? <RiLoader4Line className="size-4 animate-spin" /> : <RiAiGenerate2 className="size-4 text-primary" />}
|
|
Generate
|
|
</Button>
|
|
<div className="flex-1" />
|
|
<Button size="sm" onClick={createPr} disabled={isCreating || !isConnected}>
|
|
{isCreating ? <RiLoader4Line className="size-4 animate-spin" /> : null}
|
|
Create PR
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CollapsibleContent>
|
|
|
|
<Dialog open={checksDialogOpen} onOpenChange={setChecksDialogOpen}>
|
|
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<RiGitPullRequestLine className="h-5 w-5" />
|
|
Check Details
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{pr ? `PR #${pr.number}` : 'Pull request'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex-1 overflow-y-auto mt-2">
|
|
{isLoadingCheckDetails ? (
|
|
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
|
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
|
Loading...
|
|
</div>
|
|
) : null}
|
|
|
|
{!isLoadingCheckDetails ? (
|
|
<div className="space-y-3">
|
|
{Array.isArray(checkDetails?.checkRuns) && checkDetails?.checkRuns.length > 0 ? (
|
|
checkDetails.checkRuns.map((run, idx) => (
|
|
<div key={`${run.name}-${idx}`} className="rounded-md border border-border/60 p-3">
|
|
{renderCheckRunSummary(run)}
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-center text-muted-foreground py-8">No check details available.</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 mt-3">
|
|
<div className="flex-1" />
|
|
<Button variant="outline" onClick={() => setChecksDialogOpen(false)}>
|
|
Close
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</Collapsible>
|
|
);
|
|
};
|