mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +00:00
feat: Add AI-generated commit messages
Integrate Claude Haiku to automatically generate commit messages when committing worktree changes. Shows a sparkle animation while generating and auto-populates the commit message field. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import { createDeleteHandler } from './routes/delete.js';
|
|||||||
import { createCreatePRHandler } from './routes/create-pr.js';
|
import { createCreatePRHandler } from './routes/create-pr.js';
|
||||||
import { createPRInfoHandler } from './routes/pr-info.js';
|
import { createPRInfoHandler } from './routes/pr-info.js';
|
||||||
import { createCommitHandler } from './routes/commit.js';
|
import { createCommitHandler } from './routes/commit.js';
|
||||||
|
import { createGenerateCommitMessageHandler } from './routes/generate-commit-message.js';
|
||||||
import { createPushHandler } from './routes/push.js';
|
import { createPushHandler } from './routes/push.js';
|
||||||
import { createPullHandler } from './routes/pull.js';
|
import { createPullHandler } from './routes/pull.js';
|
||||||
import { createCheckoutBranchHandler } from './routes/checkout-branch.js';
|
import { createCheckoutBranchHandler } from './routes/checkout-branch.js';
|
||||||
@@ -64,6 +65,12 @@ export function createWorktreeRoutes(events: EventEmitter): Router {
|
|||||||
requireGitRepoOnly,
|
requireGitRepoOnly,
|
||||||
createCommitHandler()
|
createCommitHandler()
|
||||||
);
|
);
|
||||||
|
router.post(
|
||||||
|
'/generate-commit-message',
|
||||||
|
validatePathParams('worktreePath'),
|
||||||
|
requireGitRepoOnly,
|
||||||
|
createGenerateCommitMessageHandler()
|
||||||
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/push',
|
'/push',
|
||||||
validatePathParams('worktreePath'),
|
validatePathParams('worktreePath'),
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* POST /worktree/generate-commit-message endpoint - Generate an AI commit message from git diff
|
||||||
|
*
|
||||||
|
* Uses Claude Haiku to generate a concise, conventional commit message from git changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
const logger = createLogger('GenerateCommitMessage');
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
interface GenerateCommitMessageRequestBody {
|
||||||
|
worktreePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenerateCommitMessageSuccessResponse {
|
||||||
|
success: true;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenerateCommitMessageErrorResponse {
|
||||||
|
success: false;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You are a git commit message generator. Your task is to create a clear, concise commit message based on the git diff provided.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Output ONLY the commit message, nothing else
|
||||||
|
- First line should be a short summary (50 chars or less) in imperative mood
|
||||||
|
- Start with a conventional commit type if appropriate (feat:, fix:, refactor:, docs:, etc.)
|
||||||
|
- Keep it concise and descriptive
|
||||||
|
- Focus on WHAT changed and WHY (if clear from the diff), not HOW
|
||||||
|
- No quotes, backticks, or extra formatting
|
||||||
|
- If there are multiple changes, provide a brief summary on the first line
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- feat: Add dark mode toggle to settings
|
||||||
|
- fix: Resolve login validation edge case
|
||||||
|
- refactor: Extract user authentication logic
|
||||||
|
- docs: Update installation instructions`;
|
||||||
|
|
||||||
|
async function extractTextFromStream(
|
||||||
|
stream: AsyncIterable<{
|
||||||
|
type: string;
|
||||||
|
subtype?: string;
|
||||||
|
result?: string;
|
||||||
|
message?: {
|
||||||
|
content?: Array<{ type: string; text?: string }>;
|
||||||
|
};
|
||||||
|
}>
|
||||||
|
): Promise<string> {
|
||||||
|
let responseText = '';
|
||||||
|
|
||||||
|
for await (const msg of stream) {
|
||||||
|
if (msg.type === 'assistant' && msg.message?.content) {
|
||||||
|
for (const block of msg.message.content) {
|
||||||
|
if (block.type === 'text' && block.text) {
|
||||||
|
responseText += block.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||||
|
responseText = msg.result || responseText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseText;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGenerateCommitMessageHandler(): (
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
) => Promise<void> {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { worktreePath } = req.body as GenerateCommitMessageRequestBody;
|
||||||
|
|
||||||
|
if (!worktreePath || typeof worktreePath !== 'string') {
|
||||||
|
const response: GenerateCommitMessageErrorResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'worktreePath is required and must be a string',
|
||||||
|
};
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Generating commit message for worktree: ${worktreePath}`);
|
||||||
|
|
||||||
|
// Get git diff of staged and unstaged changes
|
||||||
|
let diff = '';
|
||||||
|
try {
|
||||||
|
// First try to get staged changes
|
||||||
|
const { stdout: stagedDiff } = await execAsync('git diff --cached', {
|
||||||
|
cwd: worktreePath,
|
||||||
|
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no staged changes, get unstaged changes
|
||||||
|
if (!stagedDiff.trim()) {
|
||||||
|
const { stdout: unstagedDiff } = await execAsync('git diff', {
|
||||||
|
cwd: worktreePath,
|
||||||
|
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
|
||||||
|
});
|
||||||
|
diff = unstagedDiff;
|
||||||
|
} else {
|
||||||
|
diff = stagedDiff;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get git diff:', error);
|
||||||
|
const response: GenerateCommitMessageErrorResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get git changes',
|
||||||
|
};
|
||||||
|
res.status(500).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!diff.trim()) {
|
||||||
|
const response: GenerateCommitMessageErrorResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'No changes to commit',
|
||||||
|
};
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate diff if too long (keep first 10000 characters to avoid token limits)
|
||||||
|
const truncatedDiff =
|
||||||
|
diff.length > 10000 ? diff.substring(0, 10000) + '\n\n[... diff truncated ...]' : diff;
|
||||||
|
|
||||||
|
const userPrompt = `Generate a commit message for these changes:\n\n\`\`\`diff\n${truncatedDiff}\n\`\`\``;
|
||||||
|
|
||||||
|
const stream = query({
|
||||||
|
prompt: userPrompt,
|
||||||
|
options: {
|
||||||
|
model: CLAUDE_MODEL_MAP.haiku,
|
||||||
|
systemPrompt: SYSTEM_PROMPT,
|
||||||
|
maxTurns: 1,
|
||||||
|
allowedTools: [],
|
||||||
|
permissionMode: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = await extractTextFromStream(stream);
|
||||||
|
|
||||||
|
if (!message || message.trim().length === 0) {
|
||||||
|
logger.warn('Received empty response from Claude');
|
||||||
|
const response: GenerateCommitMessageErrorResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to generate commit message - empty response',
|
||||||
|
};
|
||||||
|
res.status(500).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Generated commit message: ${message.trim().substring(0, 100)}...`);
|
||||||
|
|
||||||
|
const response: GenerateCommitMessageSuccessResponse = {
|
||||||
|
success: true,
|
||||||
|
message: message.trim(),
|
||||||
|
};
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Generate commit message failed');
|
||||||
|
const response: GenerateCommitMessageErrorResponse = {
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
};
|
||||||
|
res.status(500).json(response);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { GitCommit, Loader2 } from 'lucide-react';
|
import { GitCommit, Loader2, Sparkles } from 'lucide-react';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ export function CommitWorktreeDialog({
|
|||||||
}: CommitWorktreeDialogProps) {
|
}: CommitWorktreeDialogProps) {
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleCommit = async () => {
|
const handleCommit = async () => {
|
||||||
@@ -82,6 +83,45 @@ export function CommitWorktreeDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Generate AI commit message when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && worktree) {
|
||||||
|
// Reset state
|
||||||
|
setMessage('');
|
||||||
|
setError(null);
|
||||||
|
setIsGenerating(true);
|
||||||
|
|
||||||
|
const generateMessage = async () => {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.worktree?.generateCommitMessage) {
|
||||||
|
setError('AI commit message generation not available');
|
||||||
|
setIsGenerating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.worktree.generateCommitMessage(worktree.path);
|
||||||
|
|
||||||
|
if (result.success && result.message) {
|
||||||
|
setMessage(result.message);
|
||||||
|
} else {
|
||||||
|
// Don't show error toast, just log it and leave message empty
|
||||||
|
console.warn('Failed to generate commit message:', result.error);
|
||||||
|
setMessage('');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Don't show error toast for generation failures
|
||||||
|
console.warn('Error generating commit message:', err);
|
||||||
|
setMessage('');
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
generateMessage();
|
||||||
|
}
|
||||||
|
}, [open, worktree]);
|
||||||
|
|
||||||
if (!worktree) return null;
|
if (!worktree) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -106,10 +146,20 @@ export function CommitWorktreeDialog({
|
|||||||
|
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="commit-message">Commit Message</Label>
|
<Label htmlFor="commit-message" className="flex items-center gap-2">
|
||||||
|
Commit Message
|
||||||
|
{isGenerating && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Sparkles className="w-3 h-3 animate-pulse" />
|
||||||
|
Generating...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="commit-message"
|
id="commit-message"
|
||||||
placeholder="Describe your changes..."
|
placeholder={
|
||||||
|
isGenerating ? 'Generating commit message...' : 'Describe your changes...'
|
||||||
|
}
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setMessage(e.target.value);
|
setMessage(e.target.value);
|
||||||
@@ -118,6 +168,7 @@ export function CommitWorktreeDialog({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className="min-h-[100px] font-mono text-sm"
|
className="min-h-[100px] font-mono text-sm"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
disabled={isGenerating}
|
||||||
/>
|
/>
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -128,10 +179,14 @@ export function CommitWorktreeDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={isLoading || isGenerating}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCommit} disabled={isLoading || !message.trim()}>
|
<Button onClick={handleCommit} disabled={isLoading || isGenerating || !message.trim()}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
|||||||
@@ -1537,6 +1537,15 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
generateCommitMessage: async (worktreePath: string) => {
|
||||||
|
console.log('[Mock] Generating commit message:', { worktreePath });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
'feat: Add new feature implementation\n\nThis is a mock AI-generated commit message.',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
push: async (worktreePath: string, force?: boolean) => {
|
push: async (worktreePath: string, force?: boolean) => {
|
||||||
console.log('[Mock] Pushing worktree:', { worktreePath, force });
|
console.log('[Mock] Pushing worktree:', { worktreePath, force });
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1681,6 +1681,8 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
}),
|
}),
|
||||||
commit: (worktreePath: string, message: string) =>
|
commit: (worktreePath: string, message: string) =>
|
||||||
this.post('/api/worktree/commit', { worktreePath, message }),
|
this.post('/api/worktree/commit', { worktreePath, message }),
|
||||||
|
generateCommitMessage: (worktreePath: string) =>
|
||||||
|
this.post('/api/worktree/generate-commit-message', { worktreePath }),
|
||||||
push: (worktreePath: string, force?: boolean) =>
|
push: (worktreePath: string, force?: boolean) =>
|
||||||
this.post('/api/worktree/push', { worktreePath, force }),
|
this.post('/api/worktree/push', { worktreePath, force }),
|
||||||
createPR: (worktreePath: string, options?: any) =>
|
createPR: (worktreePath: string, options?: any) =>
|
||||||
|
|||||||
7
apps/ui/src/types/electron.d.ts
vendored
7
apps/ui/src/types/electron.d.ts
vendored
@@ -770,6 +770,13 @@ export interface WorktreeAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
// Generate an AI commit message from git diff
|
||||||
|
generateCommitMessage: (worktreePath: string) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
// Push a worktree branch to remote
|
// Push a worktree branch to remote
|
||||||
push: (
|
push: (
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ Binary file ${cleanPath} added
|
|||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
|
|
||||||
// Remove trailing empty line if the file ends with newline
|
// Remove trailing empty line if the file ends with newline
|
||||||
if (lines.length > 0 && lines.at(-1) === '') {
|
if (lines.length > 0 && lines[lines.length - 1] === '') {
|
||||||
lines.pop();
|
lines.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user