Add optional context to PR description generation (#270)
* 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
This commit is contained in:
@@ -2343,6 +2343,7 @@ pub async fn generate_pr_description(
|
|||||||
directory: String,
|
directory: String,
|
||||||
base: String,
|
base: String,
|
||||||
head: String,
|
head: String,
|
||||||
|
context: Option<String>,
|
||||||
state: State<'_, DesktopRuntime>,
|
state: State<'_, DesktopRuntime>,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
let root = validate_git_path(&directory, state.settings())
|
let root = validate_git_path(&directory, state.settings())
|
||||||
@@ -2398,8 +2399,8 @@ pub async fn generate_pr_description(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Construct PR-specific prompt
|
// 2. Construct PR-specific prompt
|
||||||
let prompt = format!(
|
let mut prompt = format!(
|
||||||
r#"You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {{\"title\": string, \"body\": string}} (ONLY JSON in response, no markdown fences) with these rules:
|
r#"You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {{"title": string, "body": string}} (ONLY JSON in response, no markdown fences) with these rules:
|
||||||
- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no \"feat:\", \"fix:\")
|
- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no \"feat:\", \"fix:\")
|
||||||
- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes
|
- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes
|
||||||
- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names
|
- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names
|
||||||
@@ -2407,15 +2408,21 @@ pub async fn generate_pr_description(
|
|||||||
- Notes: bullet list; include breaking/rollout notes only when relevant
|
- Notes: bullet list; include breaking/rollout notes only when relevant
|
||||||
Context:
|
Context:
|
||||||
- base branch: {base}
|
- base branch: {base}
|
||||||
- head branch: {head}
|
- head branch: {head}"#,
|
||||||
|
|
||||||
Diff summary:
|
|
||||||
{diffs}"#,
|
|
||||||
base = base.trim(),
|
base = base.trim(),
|
||||||
head = head.trim(),
|
head = head.trim()
|
||||||
diffs = diff_summaries
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Include additional context if provided
|
||||||
|
if let Some(ctx) = context {
|
||||||
|
let trimmed = ctx.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
prompt.push_str(&format!("\n\nAdditional context provided by user:\n{}", trimmed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt.push_str(&format!("\n\nDiff summary:\n{}", diff_summaries));
|
||||||
|
|
||||||
let model = "gpt-5-nano";
|
let model = "gpt-5-nano";
|
||||||
|
|
||||||
// 3. Call API
|
// 3. Call API
|
||||||
|
|||||||
@@ -111,13 +111,17 @@ export const createDesktopGitAPI = (): GitAPI => ({
|
|||||||
|
|
||||||
async generatePullRequestDescription(
|
async generatePullRequestDescription(
|
||||||
directory: string,
|
directory: string,
|
||||||
payload: { base: string; head: string }
|
payload: { base: string; head: string; context?: string }
|
||||||
): Promise<GeneratedPullRequestDescription> {
|
): Promise<GeneratedPullRequestDescription> {
|
||||||
return safeGitInvoke<GeneratedPullRequestDescription>('generate_pr_description', {
|
const params: { directory: string; base: string; head: string; context?: string } = {
|
||||||
directory,
|
directory,
|
||||||
base: payload.base,
|
base: payload.base,
|
||||||
head: payload.head,
|
head: payload.head,
|
||||||
});
|
};
|
||||||
|
if (payload.context?.trim()) {
|
||||||
|
params.context = payload.context.trim();
|
||||||
|
}
|
||||||
|
return safeGitInvoke<GeneratedPullRequestDescription>('generate_pr_description', params);
|
||||||
},
|
},
|
||||||
|
|
||||||
async listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]> {
|
async listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]> {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
} from '@/components/ui/collapsible';
|
} from '@/components/ui/collapsible';
|
||||||
import { generatePullRequestDescription } from '@/lib/gitApi';
|
import { generatePullRequestDescription } from '@/lib/gitApi';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useMessageStore } from '@/stores/messageStore';
|
import { useMessageStore } from '@/stores/messageStore';
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
@@ -66,6 +68,7 @@ type PullRequestDraftSnapshot = {
|
|||||||
body: string;
|
body: string;
|
||||||
draft: boolean;
|
draft: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
additionalContext: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const pullRequestDraftSnapshots = new Map<string, PullRequestDraftSnapshot>();
|
const pullRequestDraftSnapshots = new Map<string, PullRequestDraftSnapshot>();
|
||||||
@@ -102,6 +105,7 @@ export const PullRequestSection: React.FC<{
|
|||||||
const setSidebarSection = useUIStore((state) => state.setSidebarSection);
|
const setSidebarSection = useUIStore((state) => state.setSidebarSection);
|
||||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||||
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
||||||
|
const { isMobile } = useDeviceInfo();
|
||||||
|
|
||||||
const openGitHubSettings = React.useCallback(() => {
|
const openGitHubSettings = React.useCallback(() => {
|
||||||
setSidebarSection('settings');
|
setSidebarSection('settings');
|
||||||
@@ -122,6 +126,7 @@ export const PullRequestSection: React.FC<{
|
|||||||
const [title, setTitle] = React.useState(() => initialSnapshot?.title ?? branchToTitle(branch));
|
const [title, setTitle] = React.useState(() => initialSnapshot?.title ?? branchToTitle(branch));
|
||||||
const [body, setBody] = React.useState(() => initialSnapshot?.body ?? '');
|
const [body, setBody] = React.useState(() => initialSnapshot?.body ?? '');
|
||||||
const [draft, setDraft] = React.useState(() => initialSnapshot?.draft ?? false);
|
const [draft, setDraft] = React.useState(() => initialSnapshot?.draft ?? false);
|
||||||
|
const [additionalContext, setAdditionalContext] = React.useState(() => initialSnapshot?.additionalContext ?? '');
|
||||||
const [mergeMethod, setMergeMethod] = React.useState<MergeMethod>('squash');
|
const [mergeMethod, setMergeMethod] = React.useState<MergeMethod>('squash');
|
||||||
|
|
||||||
const [isGenerating, setIsGenerating] = React.useState(false);
|
const [isGenerating, setIsGenerating] = React.useState(false);
|
||||||
@@ -129,6 +134,9 @@ export const PullRequestSection: React.FC<{
|
|||||||
const [isMerging, setIsMerging] = React.useState(false);
|
const [isMerging, setIsMerging] = React.useState(false);
|
||||||
const [isMarkingReady, setIsMarkingReady] = 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 [checksDialogOpen, setChecksDialogOpen] = React.useState(false);
|
||||||
const [checkDetails, setCheckDetails] = React.useState<GitHubPullRequestContextResult | null>(null);
|
const [checkDetails, setCheckDetails] = React.useState<GitHubPullRequestContextResult | null>(null);
|
||||||
const [isLoadingCheckDetails, setIsLoadingCheckDetails] = React.useState(false);
|
const [isLoadingCheckDetails, setIsLoadingCheckDetails] = React.useState(false);
|
||||||
@@ -407,8 +415,9 @@ export const PullRequestSection: React.FC<{
|
|||||||
body,
|
body,
|
||||||
draft,
|
draft,
|
||||||
isOpen,
|
isOpen,
|
||||||
|
additionalContext,
|
||||||
});
|
});
|
||||||
}, [snapshotKey, title, body, draft, isOpen, directory, branch]);
|
}, [snapshotKey, title, body, draft, isOpen, additionalContext, directory, branch]);
|
||||||
|
|
||||||
const generateDescription = React.useCallback(async () => {
|
const generateDescription = React.useCallback(async () => {
|
||||||
if (isGenerating) return;
|
if (isGenerating) return;
|
||||||
@@ -418,6 +427,7 @@ export const PullRequestSection: React.FC<{
|
|||||||
const generated = await generatePullRequestDescription(directory, {
|
const generated = await generatePullRequestDescription(directory, {
|
||||||
base: baseBranch,
|
base: baseBranch,
|
||||||
head: branch,
|
head: branch,
|
||||||
|
context: additionalContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (generated.title?.trim()) {
|
if (generated.title?.trim()) {
|
||||||
@@ -433,7 +443,7 @@ export const PullRequestSection: React.FC<{
|
|||||||
} finally {
|
} finally {
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
}
|
}
|
||||||
}, [baseBranch, branch, directory, isGenerating]);
|
}, [baseBranch, branch, directory, isGenerating, additionalContext]);
|
||||||
|
|
||||||
const createPr = React.useCallback(async () => {
|
const createPr = React.useCallback(async () => {
|
||||||
if (!github?.prCreate) {
|
if (!github?.prCreate) {
|
||||||
@@ -731,6 +741,84 @@ export const PullRequestSection: React.FC<{
|
|||||||
<span className="typography-ui-label text-foreground select-none">Draft</span>
|
<span className="typography-ui-label text-foreground select-none">Draft</span>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ export interface GitAPI {
|
|||||||
generateCommitMessage(directory: string, files: string[]): Promise<{ message: GeneratedCommitMessage }>;
|
generateCommitMessage(directory: string, files: string[]): Promise<{ message: GeneratedCommitMessage }>;
|
||||||
generatePullRequestDescription(
|
generatePullRequestDescription(
|
||||||
directory: string,
|
directory: string,
|
||||||
payload: { base: string; head: string }
|
payload: { base: string; head: string; context?: string }
|
||||||
): Promise<GeneratedPullRequestDescription>;
|
): Promise<GeneratedPullRequestDescription>;
|
||||||
listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]>;
|
listGitWorktrees(directory: string): Promise<GitWorktreeInfo[]>;
|
||||||
addGitWorktree(directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }>;
|
addGitWorktree(directory: string, payload: GitAddWorktreePayload): Promise<{ success: boolean; path: string; branch: string }>;
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export async function generateCommitMessage(
|
|||||||
|
|
||||||
export async function generatePullRequestDescription(
|
export async function generatePullRequestDescription(
|
||||||
directory: string,
|
directory: string,
|
||||||
payload: { base: string; head: string }
|
payload: { base: string; head: string; context?: string }
|
||||||
): Promise<import('./api/types').GeneratedPullRequestDescription> {
|
): Promise<import('./api/types').GeneratedPullRequestDescription> {
|
||||||
const runtime = getRuntimeGit();
|
const runtime = getRuntimeGit();
|
||||||
if (runtime?.generatePullRequestDescription) {
|
if (runtime?.generatePullRequestDescription) {
|
||||||
|
|||||||
@@ -250,17 +250,22 @@ export async function generateCommitMessage(
|
|||||||
|
|
||||||
export async function generatePullRequestDescription(
|
export async function generatePullRequestDescription(
|
||||||
directory: string,
|
directory: string,
|
||||||
payload: { base: string; head: string }
|
payload: { base: string; head: string; context?: string }
|
||||||
): Promise<{ title: string; body: string }> {
|
): Promise<{ title: string; body: string }> {
|
||||||
const { base, head } = payload;
|
const { base, head, context } = payload;
|
||||||
if (!base || !head) {
|
if (!base || !head) {
|
||||||
throw new Error('base and head are required');
|
throw new Error('base and head are required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestBody: { base: string; head: string; context?: string } = { base, head };
|
||||||
|
if (context?.trim()) {
|
||||||
|
requestBody.context = context.trim();
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(buildUrl(`${API_BASE}/pr-description`, directory), {
|
const response = await fetch(buildUrl(`${API_BASE}/pr-description`, directory), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ base, head }),
|
body: JSON.stringify(requestBody),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -5983,6 +5983,7 @@ async function main(options = {}) {
|
|||||||
|
|
||||||
const base = typeof req.body?.base === 'string' ? req.body.base.trim() : '';
|
const base = typeof req.body?.base === 'string' ? req.body.base.trim() : '';
|
||||||
const head = typeof req.body?.head === 'string' ? req.body.head.trim() : '';
|
const head = typeof req.body?.head === 'string' ? req.body.head.trim() : '';
|
||||||
|
const context = typeof req.body?.context === 'string' ? req.body.context.trim() : '';
|
||||||
if (!base || !head) {
|
if (!base || !head) {
|
||||||
return res.status(400).json({ error: 'base and head are required' });
|
return res.status(400).json({ error: 'base and head are required' });
|
||||||
}
|
}
|
||||||
@@ -6002,7 +6003,22 @@ async function main(options = {}) {
|
|||||||
|
|
||||||
const diffSummaries = diffs.map(({ path, diff }) => `FILE: ${path}\n${diff}`).join('\n\n');
|
const diffSummaries = diffs.map(({ path, diff }) => `FILE: ${path}\n${diff}`).join('\n\n');
|
||||||
|
|
||||||
const prompt = `You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:\n- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no "feat:", "fix:")\n- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes\n- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names\n- Testing: bullet list ("- Not tested" allowed)\n- Notes: bullet list; include breaking/rollout notes only when relevant\n\nContext:\n- base branch: ${base}\n- head branch: ${head}\n\nDiff summary:\n${diffSummaries}`;
|
let prompt = `You are drafting a GitHub Pull Request title + description. Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:
|
||||||
|
- title: concise, sentence case, <= 80 chars, no trailing punctuation, no commit-style prefixes (no "feat:", "fix:")
|
||||||
|
- body: GitHub-flavored markdown with these sections in this order: Summary, Testing, Notes
|
||||||
|
- Summary: 3-6 bullet points describing user-visible changes; avoid internal helper function names
|
||||||
|
- Testing: bullet list ("- Not tested" allowed)
|
||||||
|
- Notes: bullet list; include breaking/rollout notes only when relevant
|
||||||
|
|
||||||
|
Context:
|
||||||
|
- base branch: ${base}
|
||||||
|
- head branch: ${head}`;
|
||||||
|
|
||||||
|
if (context) {
|
||||||
|
prompt += `\n\nAdditional context provided by user:\n${context}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt += `\n\nDiff summary:\n${diffSummaries}`;
|
||||||
|
|
||||||
const model = 'gpt-5-nano';
|
const model = 'gpt-5-nano';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user