From 834e7a454fd588721e5f945631ed160b5edf5688 Mon Sep 17 00:00:00 2001 From: Sid Edwards Date: Sun, 27 Oct 2024 14:41:25 -0600 Subject: [PATCH 1/9] feat(#1): Add new commit format option for issue references - Added a new commit format option called "ISSUE_REFERENCE" that follows the format: * [#ISSUE_ID]: Brief description * - Main implementation details * - Additional changes * - Impact or considerations - Related issues are automatically fetched and included in the system prompt - Existing commit format options are still supported BREAKING CHANGE: The getCommitMessage function signature has changed to accept an optional systemPrompt parameter. --- main.ts | 139 ++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 120 insertions(+), 19 deletions(-) diff --git a/main.ts b/main.ts index 72781ce..f45e558 100644 --- a/main.ts +++ b/main.ts @@ -14,6 +14,7 @@ export enum CommitFormat { KERNEL = 'kernel', REPO = 'repo', CUSTOM = 'custom', + ISSUE_REFERENCE = 'issue', // Add new format } // Then define all other functions and constants @@ -173,6 +174,24 @@ Signed-off-by: IMPORTANT: Replace all placeholders with real values from the diff.`; +const ISSUE_FORMAT = `1. Follow the issue reference format: + Rules: + 1. First line must be "[#ISSUE_ID_IF_ANY]: brief description" + 2. If no issue is found, just include the brief description + 3. Description should be clear and concise + 4. Use present tense, imperative mood + 5. Reference only issues mentioned in the diff + 6. DO NOT make up an issue ID + +RESPONSE FORMAT: +[#ISSUE_ID_IF_ANY]: brief description + +- Main implementation details +- Additional changes +- Impact or considerations + +Related: #ISSUE_ID_IF_ANY (only if mentioned in diff)`; + // Add new function to get commit history for analysis async function getCommitHistory(author?: string, limit = 50): Promise { const args = ["log", `-${limit}`, "--pretty=format:%s%n%b%n---"]; @@ -237,6 +256,82 @@ ${commits}` } } +// Add function to get related issues +async function getRelatedIssues(diff: string): Promise> { + try { + // Extract potential issue numbers from diff + const issueRefs = diff.match(/#\d+/g) || []; + const uniqueIssues = [...new Set(issueRefs.map(ref => ref.slice(1)))]; + + if (uniqueIssues.length > 0) { + // Verify existence of issues using GitHub CLI + const command = new Deno.Command("gh", { + args: ["issue", "list", + "--json", "number,title", + "--limit", "100", + "-R", ".", // current repo + ...uniqueIssues.map(issue => `#${issue}`)], + stdout: "piped", + stderr: "piped", + }); + + const output = await command.output(); + if (!output.success) { + throw new Error(`Failed to fetch issues: ${new TextDecoder().decode(output.stderr)}`); + } + + const issues = JSON.parse(new TextDecoder().decode(output.stdout)); + return issues.filter(issue => uniqueIssues.includes(issue.number.toString())); + } + + // If no direct issue references, search for issues using keywords + const keywords = extractKeywordsFromDiff(diff); + if (keywords.length === 0) return []; + + const searchCommand = new Deno.Command("gh", { + args: ["search", "issues", + "--json", "number,title", + "--limit", "5", + "-R", ".", // current repo + ...keywords], + stdout: "piped", + stderr: "piped", + }); + + const searchOutput = await searchCommand.output(); + if (!searchOutput.success) { + throw new Error(`Failed to fetch issues: ${new TextDecoder().decode(searchOutput.stderr)}`); + } + + return JSON.parse(new TextDecoder().decode(searchOutput.stdout)); + } catch { + return []; + } +} + +function extractKeywordsFromDiff(diff: string): string[] { + // Define a set of common words to ignore + const commonWords = new Set([ + 'the', 'and', 'for', 'with', 'this', 'that', 'from', 'are', 'was', 'were', + 'will', 'would', 'could', 'should', 'have', 'has', 'had', 'not', 'but', + 'or', 'if', 'then', 'else', 'when', 'while', 'do', 'does', 'did', 'done', + 'in', 'on', 'at', 'by', 'to', 'of', 'a', 'an', 'is', 'it', 'as', 'be', + 'can', 'may', 'might', 'must', 'shall', 'which', 'who', 'whom', 'whose', + 'what', 'where', 'why', 'how', 'all', 'any', 'some', 'no', 'none', 'one', + 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten' + ]); + + // Use a regular expression to extract words and identifiers + const words = diff.match(/\b\w+\b/g) || []; + + // Filter out common words and return unique keywords + const keywords = words + .filter(word => !commonWords.has(word.toLowerCase())) + .map(word => word.toLowerCase()); + + return [...new Set(keywords)]; +} + // Update getCommitMessage function signature to remove test client async function getCommitMessage( diff: string, @@ -244,8 +339,20 @@ async function getCommitMessage( systemPrompt?: string, ): Promise { const loadingId = startLoading('Generating commit message...'); - + try { + // Get related issues first + const relatedIssues = await getRelatedIssues(diff); + const selectedFormat: CommitFormat = await getDefaultFormat() || CommitFormat.CONVENTIONAL; + + // Add issue context to system prompt if using ISSUE_REFERENCE format + if (selectedFormat === CommitFormat.ISSUE_REFERENCE && relatedIssues.length > 0) { + systemPrompt += `\n\nRelated issues:\n${ + relatedIssues.map(issue => `#${issue.number}: ${issue.title}`).join('\n') + }`; + } + console.log(systemPrompt); + const anthropic = new Anthropic({ apiKey: apiKey, }); @@ -293,21 +400,9 @@ async function getCommitMessage( bodyLines.push(line); } } - - // Combine with proper spacing - const parts = [ - headerLine, - '', // Blank line after header - ...bodyLines - ]; - - // Add breaking changes with blank line before them if they exist - if (breakingChanges.length > 0) { - parts.push(''); // Extra blank line before breaking changes - parts.push(...breakingChanges); - } - - return parts.join('\n'); + + // Return the formatted commit message + return `${headerLine}\n\n${bodyLines.join('\n')}\n\n${breakingChanges.join('\n')}`; } finally { stopLoading(loadingId); } @@ -413,7 +508,7 @@ async function listAuthors(): Promise { console.log("\nRepository Authors:"); console.log('┌────────┬──────────────────────────────────────────────────────────────┐'); console.log('│ Commits│ Author │'); - console.log('├────────┼──────────────────────────────────────────────────────────────┤'); + console.log('├────────┼───────────────────────────────────────────────────────────────'); authors.forEach(({ count, author }) => { const countStr = count.toString().padStart(6); @@ -527,6 +622,8 @@ async function getFormatTemplate(format: CommitFormat, author?: string): Promise case CommitFormat.REPO: case CommitFormat.CUSTOM: return await getStoredCommitStyle() || CONVENTIONAL_FORMAT; + case CommitFormat.ISSUE_REFERENCE: + return ISSUE_FORMAT; default: return CONVENTIONAL_FORMAT; } @@ -680,6 +777,8 @@ For more information, visit: https://github.com/sidedwards/auto-commit selectedFormat = CommitFormat.ANGULAR; } else if (formatInput.includes('con')) { selectedFormat = CommitFormat.CONVENTIONAL; + } else if (formatInput.includes('iss')) { + selectedFormat = CommitFormat.ISSUE_REFERENCE; } } else { selectedFormat = await getDefaultFormat() || CommitFormat.CONVENTIONAL; @@ -716,7 +815,8 @@ For more information, visit: https://github.com/sidedwards/auto-commit '1': CommitFormat.CONVENTIONAL, '2': CommitFormat.SEMANTIC, '3': CommitFormat.ANGULAR, - '4': CommitFormat.KERNEL + '4': CommitFormat.KERNEL, + '5': CommitFormat.ISSUE_REFERENCE }; console.log("\nChoose default commit format:"); @@ -724,8 +824,9 @@ For more information, visit: https://github.com/sidedwards/auto-commit console.log("2. Semantic (with emojis)"); console.log("3. Angular"); console.log("4. Linux Kernel"); + console.log("5. Issue Reference ([#123]: description)"); - const formatChoice = prompt("Select format (1-4): ") || "1"; + const formatChoice = prompt("Select format (1-5): ") || "1"; selectedFormat = formatChoices[formatChoice as keyof typeof formatChoices] || CommitFormat.CONVENTIONAL; // Store the choice From 3b2123ec67128e2d1f3e0d832f91dc96c2fb4946 Mon Sep 17 00:00:00 2001 From: Sid Edwards Date: Sun, 27 Oct 2024 16:40:31 -0600 Subject: [PATCH 2/9] feat: Update allowed commands - Add "gh" to the allowed commands for the "start", "install", and "build" tasks - This allows the script to use the GitHub CLI for fetching issue information chore(main.ts): Add support for GitHub issue search and selection - Add new function `searchAndSelectIssue` to search for and select a related issue - Update `getCommitMessage` to include the selected issue information in the system prompt - Add new function `getGitAuthor` to retrieve the git author information for the kernel format - Improve formatting of the commit message display --- deno.jsonc | 6 +- main.ts | 526 ++++++++++++++++++++++++++++----------------- scripts/install.ts | 2 +- 3 files changed, 330 insertions(+), 204 deletions(-) diff --git a/deno.jsonc b/deno.jsonc index f01bb25..8198a3d 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,8 +1,8 @@ { "tasks": { - "start": "deno run --allow-net --allow-read --allow-write --allow-env --allow-run=\"git,vim\" main.ts", - "install": "deno run --allow-read --allow-write --allow-run scripts/install.ts", - "build": "deno run --allow-read --allow-write --allow-run scripts/build.ts", + "start": "deno run --allow-net --allow-read --allow-write --allow-env --allow-run=\"git,vim,gh\" main.ts", + "install": "deno run --allow-read --allow-write --allow-run=\"git,vim,gh\" scripts/install.ts", + "build": "deno run --allow-read --allow-write --allow-run=\"git,vim,gh\" scripts/build.ts", "update": "git pull && deno task install" }, "name": "auto-commit", diff --git a/main.ts b/main.ts index f45e558..0917fe0 100644 --- a/main.ts +++ b/main.ts @@ -26,6 +26,7 @@ const COLORS = { dim: chalk.dim, bold: chalk.bold, header: chalk.cyan.bold, + action: chalk.yellow.bold, // Add this for action keys }; // Update startLoading with chalk @@ -88,8 +89,13 @@ const CONVENTIONAL_FORMAT = `1. Follow the format: 5. Mark breaking changes as: BREAKING CHANGE: +6. If a referenced issue ID is available, replace with and include it as: + (#): +7. If no referenced issue ID is available add a scope based on the changes and format like: + (): + RESPONSE FORMAT: -(): (only one per commit) +( or #): (only include once per commit) - Main change description * Impact or detail @@ -117,16 +123,19 @@ const SEMANTIC_FORMAT = `1. Follow the format: 1. Start with an emoji 2. Use present tense 3. First line is summary - 4. Include issue references + 4. If a referenced issue ID is available, add a new line at the end: + Reference: # + 5. If no issue ID is available, do not include the Reference line RESPONSE FORMAT: :emoji: - + - Change detail 1 - Change detail 2 +- ... -`; +Reference: # (if available)`; const ANGULAR_FORMAT = `1. Follow Angular's commit format: Types: @@ -145,10 +154,13 @@ const ANGULAR_FORMAT = `1. Follow Angular's commit format: 2. No period at end 3. Optional body with details 4. Breaking changes marked - 5. Only include a single (): line maximum + 5. If a referenced issue ID is available, replace with and include it as: + (#): + 6. If no referenced issue ID is available add a scope based on the changes and format like: + (): RESPONSE FORMAT: -(): (only one per commit) +( or #): (only one per commit) * Change detail 1 * Change detail 2 @@ -162,7 +174,10 @@ const KERNEL_FORMAT = `1. Follow Linux kernel format: 3. Description should be clear and concise 4. Body explains the changes in detail 5. Wrap all lines at 72 characters - 6. End with Signed-off-by line + 6. End with Signed-off-by line using git author info + 7. Never include the diff or any git output + 8. If a referenced issue ID is available, add a new line above the Signed-off-by line for Reference with the issue ID like: + Reference: # RESPONSE FORMAT: : @@ -170,9 +185,9 @@ RESPONSE FORMAT: -Signed-off-by: +Signed-off-by: {{GIT_AUTHOR}} -IMPORTANT: Replace all placeholders with real values from the diff.`; +Reference: # (if available)`; const ISSUE_FORMAT = `1. Follow the issue reference format: Rules: @@ -189,8 +204,7 @@ RESPONSE FORMAT: - Main implementation details - Additional changes - Impact or considerations - -Related: #ISSUE_ID_IF_ANY (only if mentioned in diff)`; +`; // Add new function to get commit history for analysis async function getCommitHistory(author?: string, limit = 50): Promise { @@ -263,6 +277,8 @@ async function getRelatedIssues(diff: string): Promise ref.slice(1)))]; + let issues = []; + if (uniqueIssues.length > 0) { // Verify existence of issues using GitHub CLI const command = new Deno.Command("gh", { @@ -280,30 +296,33 @@ async function getRelatedIssues(diff: string): Promise uniqueIssues.includes(issue.number.toString())); + issues = JSON.parse(new TextDecoder().decode(output.stdout)); } // If no direct issue references, search for issues using keywords - const keywords = extractKeywordsFromDiff(diff); - if (keywords.length === 0) return []; - - const searchCommand = new Deno.Command("gh", { - args: ["search", "issues", - "--json", "number,title", - "--limit", "5", - "-R", ".", // current repo - ...keywords], - stdout: "piped", - stderr: "piped", - }); + if (issues.length === 0) { + const keywords = extractKeywordsFromDiff(diff); + if (keywords.length > 0) { + const searchCommand = new Deno.Command("gh", { + args: ["issue", "search", + "--json", "number,title", + "--limit", "5", + "-R", ".", // current repo + ...keywords], + stdout: "piped", + stderr: "piped", + }); + + const searchOutput = await searchCommand.output(); + if (!searchOutput.success) { + throw new Error(`Failed to fetch issues: ${new TextDecoder().decode(searchOutput.stderr)}`); + } - const searchOutput = await searchCommand.output(); - if (!searchOutput.success) { - throw new Error(`Failed to fetch issues: ${new TextDecoder().decode(searchOutput.stderr)}`); + issues = JSON.parse(new TextDecoder().decode(searchOutput.stdout)); + } } - return JSON.parse(new TextDecoder().decode(searchOutput.stdout)); + return issues; } catch { return []; } @@ -332,31 +351,66 @@ function extractKeywordsFromDiff(diff: string): string[] { return [...new Set(keywords)]; } -// Update getCommitMessage function signature to remove test client +// Add new function to get git author info +async function getGitAuthor(): Promise<{ name: string, email: string }> { + const nameCmd = new Deno.Command("git", { + args: ["config", "user.name"], + stdout: "piped", + }); + const emailCmd = new Deno.Command("git", { + args: ["config", "user.email"], + stdout: "piped", + }); + + const [nameOutput, emailOutput] = await Promise.all([ + nameCmd.output(), + emailCmd.output(), + ]); + + return { + name: new TextDecoder().decode(nameOutput.stdout).trim(), + email: new TextDecoder().decode(emailOutput.stdout).trim() + }; +} + +// Update getCommitMessage to include author info for kernel format async function getCommitMessage( diff: string, apiKey: string, systemPrompt?: string, + selectedIssue?: { number: number, title: string } | null, + selectedFormat?: CommitFormat ): Promise { + if (selectedFormat === CommitFormat.KERNEL) { + const author = await getGitAuthor(); + systemPrompt = `${systemPrompt}\n\nGit Author: ${author.name} <${author.email}>`; + } const loadingId = startLoading('Generating commit message...'); try { // Get related issues first const relatedIssues = await getRelatedIssues(diff); - const selectedFormat: CommitFormat = await getDefaultFormat() || CommitFormat.CONVENTIONAL; - // Add issue context to system prompt if using ISSUE_REFERENCE format - if (selectedFormat === CommitFormat.ISSUE_REFERENCE && relatedIssues.length > 0) { + // Add issue context to system prompt if available + if (selectedIssue) { + systemPrompt += `\n\nReferenced issue: #${selectedIssue.number}: ${selectedIssue.title} +Include the issue ID as a reference according to the commit message format.`; + } else { + systemPrompt += `\n\nNo issue referenced`; + } + + if (relatedIssues.length > 0) { systemPrompt += `\n\nRelated issues:\n${ relatedIssues.map(issue => `#${issue.number}: ${issue.title}`).join('\n') }`; } - console.log(systemPrompt); const anthropic = new Anthropic({ apiKey: apiKey, }); + // console.log(systemPrompt); + const msg = await anthropic.messages.create({ model: "claude-3-haiku-20240307", max_tokens: 1024, @@ -364,16 +418,17 @@ async function getCommitMessage( system: systemPrompt, messages: [{ role: "user", - content: `Generate a commit message for these changes:\n\n${diff}\n\nIMPORTANT: -1. Generate ONLY the commit message -2. Do not include any explanatory text or formatting + content: `Generate a commit message for ALL key changes from the diff:\n\n${diff}\n\nIMPORTANT: +1. Do not include any explanatory text or formatting +2. Do not make up features, changes, or issue numbers not present in the diff 3. Do not repeat the header line -4. Follow this exact structure: +4. IMPORTANT: NEVER include the diff in the response +5. Do not include "diff --git" or any git output +6. Follow this exact structure: - One header line - One blank line - - Bullet points for changes - - Breaking changes (if any) -5. Never include the diff or any git output` + - Bullet points for actual changes + - Breaking changes (if any)` }], }); @@ -570,18 +625,19 @@ async function getDefaultFormat(): Promise { function createFileTree(files: string[]): string[] { const tree: string[] = []; const sortedFiles = files.sort(); + const maxWidth = 68; // Width of box content for (const file of sortedFiles) { const parts = file.split('/'); - let prefix = ''; if (parts.length === 1) { - tree.push(`${COLORS.dim('├──')} ${COLORS.info(file)}`); + const line = `${COLORS.dim('├──')} ${COLORS.info(file)}`; + tree.push(line.padEnd(maxWidth)); } else { const fileName = parts.pop()!; - const dir = parts.join('/'); - prefix = COLORS.dim('│ ').repeat(parts.length - 1); - tree.push(`${prefix}${COLORS.dim('├──')} ${COLORS.info(fileName)}`); + const prefix = COLORS.dim('│ ').repeat(parts.length - 1); + const line = `${prefix}${COLORS.dim('├──')} ${COLORS.info(fileName)}`; + tree.push(line.padEnd(maxWidth)); } } @@ -629,6 +685,157 @@ async function getFormatTemplate(format: CommitFormat, author?: string): Promise } } +async function getRepoInfo(): Promise { + const command = new Deno.Command("git", { + args: ["remote", "get-url", "origin"], + stdout: "piped", + stderr: "piped", + }); + + const output = await command.output(); + if (!output.success) { + console.error(`Failed to get remote URL: ${new TextDecoder().decode(output.stderr)}`); + return null; + } + + const url = new TextDecoder().decode(output.stdout).trim(); + const match = url.match(/[:/]([^/]+\/[^/.]+)(\.git)?$/); + return match ? match[1] : null; +} + +async function isGitHubRepo(): Promise { + const command = new Deno.Command("git", { + args: ["remote", "get-url", "origin"], + stdout: "piped", + stderr: "piped", + }); + + try { + const output = await command.output(); + if (!output.success) return false; + + const url = new TextDecoder().decode(output.stdout).trim(); + return url.includes('github.com'); + } catch { + return false; + } +} + +async function searchAndSelectIssue(): Promise<{ number: number, title: string } | null> { + const repo = await getRepoInfo(); + if (!repo) { + console.error("Could not determine the repository information."); + return null; + } + + const isGitHub = await isGitHubRepo(); + if (!isGitHub) { + return null; + } + + const keywords = prompt("Enter keywords to search for issues (or press Enter to skip): "); + if (!keywords) return null; + + const searchCommand = new Deno.Command("gh", { + args: ["issue", "list", + "--search", keywords, + "--json", "number,title", + "--limit", "5", + "-R", repo], // Use the dynamically determined repo + stdout: "piped", + stderr: "piped", + }); + + const searchOutput = await searchCommand.output(); + if (!searchOutput.success) { + console.error(`Failed to search issues: ${new TextDecoder().decode(searchOutput.stderr)}`); + return null; + } + + const issues = JSON.parse(new TextDecoder().decode(searchOutput.stdout)); + if (issues.length === 0) { + console.log("No issues found."); + return null; + } + + console.log(`\n${COLORS.header("Found issues:")}`); + console.log('┌──────┬────────┬───────────────────────��──────────────────────────────────┐'); + console.log('│ Sel# │ ID │ Title │'); + console.log('├──────┼────────┼──────────────────────────────────────────────────────────┤'); + + interface Issue { + number: number; + title: string; + } + + issues.forEach((issue: Issue, index: number) => { + console.log( + `│ ${(index + 1).toString().padEnd(3)} │ ` + + `#${issue.number.toString().padEnd(5)} │ ` + + `${issue.title.slice(0, 50).padEnd(50)} │` + ); + }); + console.log('└──────┴────────┴──────────────────────────────────────────────────────────┘\n'); + + const choice = prompt("Select an issue by number (or press Enter to skip): "); + const selectedIndex = parseInt(choice || "", 10) - 1; + if (selectedIndex >= 0 && selectedIndex < issues.length) { + return issues[selectedIndex]; + } + + return null; +} + +// Add formatting helper functions +function createBox(content: string): string { + const maxWidth = 70; + const contentWidth = maxWidth - 2; // Account for borders + const horizontal = '─'.repeat(maxWidth); + + // Helper to wrap text + function wrapText(text: string): string[] { + const words = text.split(' '); + const lines: string[] = []; + let currentLine = ''; + + words.forEach(word => { + if ((currentLine + ' ' + word).length <= contentWidth) { + currentLine += (currentLine ? ' ' : '') + word; + } else { + if (currentLine) lines.push(currentLine); + currentLine = word; + } + }); + if (currentLine) lines.push(currentLine); + return lines; + } + + // Process all lines and wrap them + const wrappedLines = content.split('\n').flatMap(line => wrapText(line)); + + let result = `┌${horizontal}┐\n`; + wrappedLines.forEach(line => { + result += `│${line.padEnd(maxWidth - 2)}│\n`; + }); + result += `└${horizontal}┘`; + + return result; +} + +async function commitChanges(message: string): Promise { + const command = new Deno.Command("git", { + args: ["commit", "-m", message], + stdout: "piped", + stderr: "piped", + }); + + const output = await command.output(); + if (!output.success) { + throw new Error(`Failed to commit: ${new TextDecoder().decode(output.stderr)}`); + } + console.log("\n✓ Changes committed successfully!"); +} + // Update main function to use stored styles async function main(): Promise { const flags = parse(Deno.args, { @@ -637,7 +844,15 @@ async function main(): Promise { alias: { h: "help" }, }); - let selectedFormat = await getDefaultFormat() || CommitFormat.CONVENTIONAL; + // Update format selection logic + let selectedFormat = flags.format + ? (Object.values(CommitFormat).find(f => f.startsWith(flags.format?.toLowerCase() || '')) || CommitFormat.CONVENTIONAL) + : (await getDefaultFormat() || CommitFormat.CONVENTIONAL); + + // Store the selected format + if (flags.format) { + await storeDefaultFormat(selectedFormat); + } // Handle --help flag if (flags.help) { @@ -753,37 +968,23 @@ For more information, visit: https://github.com/sidedwards/auto-commit selectedFormat = CommitFormat.SEMANTIC; } else if (formatInput.includes('ang')) { selectedFormat = CommitFormat.ANGULAR; + } else if (formatInput.includes('con')) { + selectedFormat = CommitFormat.CONVENTIONAL; + } else if (formatInput.includes('iss')) { + selectedFormat = CommitFormat.ISSUE_REFERENCE; } const template = selectedFormat === CommitFormat.KERNEL ? KERNEL_FORMAT : selectedFormat === CommitFormat.SEMANTIC ? SEMANTIC_FORMAT : selectedFormat === CommitFormat.ANGULAR ? ANGULAR_FORMAT : + selectedFormat === CommitFormat.ISSUE_REFERENCE ? ISSUE_FORMAT : CONVENTIONAL_FORMAT; await storeCommitStyle(template); await storeDefaultFormat(selectedFormat); } - // Use format flag if provided - if (typeof flags.format === 'string') { // Type check the flag - const formatInput = flags.format.toLowerCase(); - // Handle common typos and variations - if (formatInput.includes('kern')) { - selectedFormat = CommitFormat.KERNEL; - } else if (formatInput.includes('sem')) { - selectedFormat = CommitFormat.SEMANTIC; - } else if (formatInput.includes('ang')) { - selectedFormat = CommitFormat.ANGULAR; - } else if (formatInput.includes('con')) { - selectedFormat = CommitFormat.CONVENTIONAL; - } else if (formatInput.includes('iss')) { - selectedFormat = CommitFormat.ISSUE_REFERENCE; - } - } else { - selectedFormat = await getDefaultFormat() || CommitFormat.CONVENTIONAL; - } - if (flags.learn) { try { const commits = await getCommitHistory(flags.author); @@ -836,157 +1037,57 @@ For more information, visit: https://github.com/sidedwards/auto-commit } try { - // Get staged files first - const stagedFiles = await getStagedFiles(); + // Move selectedIssue outside the try block so it persists across retries + const selectedIssue = await searchAndSelectIssue(); - if (stagedFiles.length === 0) { - console.log("\n" + COLORS.warning("⚠") + " No staged changes found. Add files first with:"); - console.log("\n " + COLORS.dim("git add ")); - console.log("\n " + COLORS.dim("git add -p")); - return; - } + while (true) { // Add loop for retries + const diff = await getDiff(); + const apiKey = await getStoredApiKey(); + const systemPrompt = await getFormatTemplate(selectedFormat); - // Show files that will be committed - console.log("\n" + COLORS.header("Staged files to be committed:")); - console.log(COLORS.dim('┌' + '─'.repeat(72) + '┐')); - createFileTree(stagedFiles).forEach(line => { - console.log(COLORS.dim('│ ') + line.padEnd(70) + COLORS.dim(' │')); - }); - console.log(COLORS.dim('└' + '─'.repeat(72) + '┘\n')); + if (!apiKey) { + throw new Error("API key is required"); + } + const commitMessage = await getCommitMessage(diff, apiKey, systemPrompt, selectedIssue, selectedFormat); - // Confirm before proceeding - const proceed = prompt("Generate commit message for these files? (y/n) "); - if (proceed?.toLowerCase() !== 'y') { - console.log("\n" + COLORS.error("✗") + " Operation cancelled."); - return; - } + // Show staged files first with bold header + console.log(`\n${COLORS.header("Staged files to be committed:")}`); + const stagedFiles = await getStagedFiles(); + console.log(createBox(createFileTree(stagedFiles).join('\n'))); - // Get the diff and generate message - try { - const diff = await checkStagedChanges(); - // Get the appropriate format template - const formatTemplate = await getFormatTemplate(selectedFormat, flags.author); - - // Create the system prompt - const systemPrompt = `You are a Git Commit Message Generator. Generate ONLY a commit message following this format: - -${formatTemplate} - -IMPORTANT: -1. Base your message on ALL changes in the diff -2. Consider ALL files being modified (${stagedFiles.join(', ')}) -3. Do not focus only on the first file -4. Summarize the overall changes across all files -5. Include significant changes from each modified file -6. Do not make assumptions or add fictional features -7. Never include issue numbers unless they appear in the diff -8. Do not include any format templates or placeholders -9. Never output the response format template itself -10. Only include ONE header line -11. Never duplicate any lines, especially the header -12. Sort changes by priority and logical groups -13. Never include preamble or explanation -14. Never include the diff or any git-specific output -15. Structure should be: - - Single header line - - Blank line - - Body with bullet points - - Breaking changes (if any)`; - - const commitMessage = await getCommitMessage( - diff, - apiKey, - systemPrompt - ); - - console.log("\n" + COLORS.header("Proposed commit:") + "\n"); - console.log(COLORS.dim('┌' + '─'.repeat(72) + '┐')); - console.log(commitMessage.split('\n').map(line => { - // If line is longer than 70 chars, wrap it - if (line.length > 70) { - const words = line.split(' '); - let currentLine = ''; - const wrappedLines = []; - - words.forEach(word => { - if ((currentLine + ' ' + word).length <= 70) { - currentLine += (currentLine ? ' ' : '') + word; - } else { - wrappedLines.push(`│ ${currentLine.padEnd(70)} │`); - currentLine = word; - } - }); - if (currentLine) { - wrappedLines.push(`│ ${currentLine.padEnd(70)} │`); - } - return wrappedLines.join('\n'); - } - // If line is <= 70 chars, pad it as before - return `│ ${line.padEnd(70)} │`; - }).join('\n')); - console.log(COLORS.dim('└' + '─'.repeat(72) + '┘\n')); + const proceed = prompt("\nGenerate commit message for these files? (y/n) "); + if (proceed?.toLowerCase() !== 'y') { + return; + } - const choice = prompt("(a)ccept, (e)dit, (r)eject, (n)ew message? "); + displayCommitMessage(commitMessage); + // Format action keys in bold + const choice = prompt(`\n${COLORS.action("(a)")}ccept, ${COLORS.action("(e)")}dit, ${COLORS.action("(r)")}eject, ${COLORS.action("(n)")}ew message? `); + switch (choice?.toLowerCase()) { - case 'a': { - // Implement actual git commit here - const commitCommand = new Deno.Command("git", { - args: ["commit", "-m", commitMessage], - stdout: "piped", - stderr: "piped", - }); - - const commitResult = await commitCommand.output(); - if (!commitResult.success) { - throw new Error(`Failed to commit: ${new TextDecoder().decode(commitResult.stderr)}`); - } - console.log("\n" + COLORS.success("✓") + " Changes committed!"); - break; - } + case 'a': + await commitChanges(commitMessage); + return; case 'e': { const editedMessage = await editInEditor(commitMessage); - if (editedMessage !== commitMessage) { - console.log("\nEdited commit:\n"); - console.log('┌' + '─'.repeat(72) + '┐'); - console.log(editedMessage.split('\n').map(line => `│ ${line.padEnd(70)} │`).join('\n')); - console.log('└' + '─'.repeat(72) + '┘\n'); - - // Implement git commit with edited message - const editedCommitCommand = new Deno.Command("git", { - args: ["commit", "-m", editedMessage], - stdout: "piped", - stderr: "piped", - }); - - const editedCommitResult = await editedCommitCommand.output(); - if (!editedCommitResult.success) { - throw new Error(`Failed to commit: ${new TextDecoder().decode(editedCommitResult.stderr)}`); - } - console.log("\n" + COLORS.success("✓") + " Changes committed with edited message!"); + if (editedMessage) { + await commitChanges(editedMessage); } - break; + return; } case 'n': - // Generate a new message with slightly different temperature - return await main(); // Restart the process + continue; // Continue the loop instead of calling main() recursively case 'r': - console.log("\n" + COLORS.error("✗") + " Commit message rejected."); - break; + console.log("\n✗ Commit message rejected."); + return; default: - console.log("\n⚠ Invalid selection."); + console.log("\n✗ Invalid choice. Commit cancelled."); + return; } - } catch (error) { - console.error("Failed to generate commit message:", error); - return; } } catch (error) { - if (error instanceof Error) { - console.error(COLORS.error("Error:") + " An error occurred:", error.message); - } else { - console.error(COLORS.error("Error:") + " An unexpected error occurred"); - } - return; + console.error("An error occurred:", error); } } @@ -1019,3 +1120,28 @@ async function storeApiKey(apiKey: string): Promise { if (import.meta.main) { main(); } + +async function getDiff(): Promise { + const command = new Deno.Command("git", { + args: ["diff", "--staged"], + stdout: "piped", + stderr: "piped", + }); + + const output = await command.output(); + if (!output.success) { + const errorMessage = new TextDecoder().decode(output.stderr); + throw new Error(`Failed to get diff: ${errorMessage}`); + } + + return new TextDecoder().decode(output.stdout); +} + +// Replace createBox with a simpler message display +function displayCommitMessage(message: string): void { + console.log(COLORS.header("\nProposed commit:")); + console.log(COLORS.dim("─".repeat(80))); + console.log(message); + console.log(COLORS.dim("─".repeat(80))); +} + diff --git a/scripts/install.ts b/scripts/install.ts index c0b1a16..cd2d386 100644 --- a/scripts/install.ts +++ b/scripts/install.ts @@ -28,7 +28,7 @@ async function install() { "--allow-read", "--allow-write", "--allow-env", - "--allow-run=git,vim", + "--allow-run=git,vim,gh", "--output", scriptPath, "main.ts" From eb9ab524ed976b86c365baaaee6347354cc95d4c Mon Sep 17 00:00:00 2001 From: Sid Edwards Date: Sun, 27 Oct 2024 16:41:38 -0600 Subject: [PATCH 3/9] feat: improve commit gen --- main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.ts b/main.ts index 0917fe0..bf8b74e 100644 --- a/main.ts +++ b/main.ts @@ -418,7 +418,7 @@ Include the issue ID as a reference according to the commit message format.`; system: systemPrompt, messages: [{ role: "user", - content: `Generate a commit message for ALL key changes from the diff:\n\n${diff}\n\nIMPORTANT: + content: `Generate a commit message summarizing all key changes from the diff:\n\n${diff}\n\nIMPORTANT: 1. Do not include any explanatory text or formatting 2. Do not make up features, changes, or issue numbers not present in the diff 3. Do not repeat the header line From 98f29490d97ac95c0e82e1ebab0503d50050b1f2 Mon Sep 17 00:00:00 2001 From: Sid Edwards Date: Sun, 27 Oct 2024 16:45:48 -0600 Subject: [PATCH 4/9] chore: Update GitHub Actions workflows and build script - Upgrade actions/checkout to v4 - Update Deno version to 1.41.0 in test-build workflow - Add permissions for pull-requests in test-build workflow - Add --allow-run=gh to build script for GitHub CLI access - Exit with error if any build fails BREAKING CHANGE: The minimum required Deno version is now 1.41.0 --- .github/workflows/release.yml | 2 +- .github/workflows/test-build.yml | 12 +++++++++--- scripts/build.ts | 4 ++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14511c1..c4b6595 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Deno uses: denoland/setup-deno@v1 diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index e13867c..0b06475 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -8,20 +8,26 @@ on: pull_request: branches: [ main, master ] +permissions: + contents: read + pull-requests: read + jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Deno uses: denoland/setup-deno@v1 with: - deno-version: "1.37.0" + deno-version: "1.41.0" + + - name: Run Tests + run: deno test -A - name: Test Build run: | rm -f deno.lock deno cache main.ts deno task build - diff --git a/scripts/build.ts b/scripts/build.ts index 4d9a0fe..69c19d6 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -46,7 +46,7 @@ async function build() { "--allow-read", "--allow-write", "--allow-env", - "--allow-run=git,vim", + "--allow-run=git,vim,gh", // Added gh for GitHub CLI "--target", target.target, "--output", @@ -60,7 +60,7 @@ async function build() { const result = await command.output(); if (!result.success) { console.error(`Failed to build for ${target.platform}`); - continue; + Deno.exit(1); // Exit with error if any build fails } } From 0b6057c912f5bcc475846ffed7853584c5664060 Mon Sep 17 00:00:00 2001 From: Sid Edwards Date: Sun, 27 Oct 2024 16:46:53 -0600 Subject: [PATCH 5/9] chore: Refactor CI workflow - Removed `deno.lock` file before caching dependencies - Moved `deno test -A` command to run before the build step - Simplified the build step to just run `deno task build` --- .github/workflows/test-build.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 0b06475..2f10680 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -24,10 +24,10 @@ jobs: deno-version: "1.41.0" - name: Run Tests - run: deno test -A - - - name: Test Build run: | rm -f deno.lock deno cache main.ts - deno task build + deno test -A + + - name: Test Build + run: deno task build From 4bfe75c4ead5480ac91837b58466e4efe34e028f Mon Sep 17 00:00:00 2001 From: Sid Edwards Date: Sun, 27 Oct 2024 16:48:05 -0600 Subject: [PATCH 6/9] chore: Update test-build workflow - Removed separate "Run Tests" step and combined it with "Test Build" step - Removed `deno test -A` command and only ran `deno task build` --- .github/workflows/test-build.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 2f10680..642f049 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -23,11 +23,8 @@ jobs: with: deno-version: "1.41.0" - - name: Run Tests + - name: Test Build run: | rm -f deno.lock deno cache main.ts - deno test -A - - - name: Test Build - run: deno task build + deno task build From 38a531c31f3c4f247c09e32e173110c1c76c012e Mon Sep 17 00:00:00 2001 From: Sid Edwards Date: Sun, 27 Oct 2024 16:49:50 -0600 Subject: [PATCH 7/9] chore(scripts): update allowed permissions - Update the allowed permissions for the build and install scripts to include "git", "vim", "gh", and "deno" - This allows the scripts to run additional commands required for the build and installation process --- scripts/build.ts | 2 +- scripts/install.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/build.ts b/scripts/build.ts index 69c19d6..69441c9 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run +#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run="git,vim,gh,deno" import { ensureDir } from "https://deno.land/std/fs/ensure_dir.ts"; import { join } from "https://deno.land/std/path/mod.ts"; diff --git a/scripts/install.ts b/scripts/install.ts index cd2d386..a53ac57 100644 --- a/scripts/install.ts +++ b/scripts/install.ts @@ -1,4 +1,4 @@ -#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run +#!/usr/bin/env -S deno run --allow-read --allow-write --allow-run="git,vim,gh,deno" import { join } from "https://deno.land/std/path/mod.ts"; From 7df9562fc7c88b4eb614224f375930d7ccf2e49a Mon Sep 17 00:00:00 2001 From: Sid Edwards Date: Sun, 27 Oct 2024 16:51:09 -0600 Subject: [PATCH 8/9] chore(workflow): Update build script - Replace `deno task build` with `deno run --allow-read --allow-write --allow-run="git,vim,gh,deno" scripts/build.ts` - Remove `deno.lock` file before caching main.ts --- .github/workflows/test-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 642f049..02ac446 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -27,4 +27,4 @@ jobs: run: | rm -f deno.lock deno cache main.ts - deno task build + deno run --allow-read --allow-write --allow-run="git,vim,gh,deno" scripts/build.ts From ef1166af5d1ef6f5e5876236c5593165cb381f03 Mon Sep 17 00:00:00 2001 From: Sid Edwards Date: Sun, 27 Oct 2024 16:58:10 -0600 Subject: [PATCH 9/9] chore(release.yml): Update release workflow - Remove deno.lock file before caching - Use deno run to execute build script instead of deno task - Update GitHub Actions script to generate release notes --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c4b6595..1b12690 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: run: | rm -f deno.lock deno cache main.ts - deno task build + deno run --allow-read --allow-write --allow-run="git,vim,gh,deno" scripts/build.ts - name: Generate Release Notes uses: actions/github-script@v7