diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66d62f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +**/.claude/settings.local.json diff --git a/index.js b/index.js index d3725ac..99542ea 100755 --- a/index.js +++ b/index.js @@ -1,7 +1,7 @@ #!/usr/bin/env node import { execSync } from 'child_process'; -import { input, confirm } from '@inquirer/prompts'; +import { input, confirm, search } from '@inquirer/prompts'; import twig from 'twig'; import { promisify } from 'util'; import { existsSync } from 'fs'; @@ -54,6 +54,44 @@ export function getCurrentBranch() { } } +// Get default branch name +export function getDefaultBranch() { + try { + // Try to get the remote's default branch + // First get the default remote (usually origin) + const remote = execSync('git remote').toString().trim().split('\n')[0]; + + // Then get the default branch (what HEAD points to) + const output = execSync(`git remote show ${remote} | grep "HEAD branch"`).toString().trim(); + const match = output.match(/HEAD branch:\s*(.+)$/); + + if (match && match[1]) { + return match[1]; + } + + // Fallback to 'main' or 'master' if we can't determine it + return 'main'; + } catch { + // Fallback to a sensible default + return 'main'; + } +} + +// Get list of remote branches +export function getRemoteBranches() { + try { + // Get all remote branches, excluding HEAD reference + const output = execSync('git branch -r | grep -v HEAD').toString().trim(); + + // Parse and clean branch names + return output.split('\n') + .map(branch => branch.trim().replace(/^origin\//, '')) + .filter(branch => branch !== ''); + } catch { + return []; + } +} + // Check if branch is pushed to remote export function isBranchPushedToRemote(branchName) { try { @@ -86,21 +124,62 @@ export function checkGhCli() { } } -// Get the template path +// Default PR template content as a fallback +const DEFAULT_TEMPLATE = `{% if has_ticket %} +## Ticket +{{ ticket_number }} +{% endif %} + +## Changes +{% for change in changes %} +- {{ change }} +{% endfor %} + +{% if has_tests %} +## Tests +- āœ… Includes tests +{% else %} +## Tests +- āŒ No tests included +{% endif %} +`; + +// Get the directory where the script is installed +function getScriptDir() { + // Use import.meta.url to get the full URL of the current module + const fileUrl = import.meta.url; + // Convert the file URL to a system path and get the directory + return path.dirname(new URL(fileUrl).pathname); +} + +// Get the template path or create default template export function getTemplatePath() { - const templatePath = path.join(__dirname, 'templates', 'PULL_REQUEST_TEMPLATE.twig'); + // Get template ONLY from the script's installation directory + const scriptDir = getScriptDir(); + const templatePath = path.join(scriptDir, 'templates', 'PULL_REQUEST_TEMPLATE.twig'); + // Check if template exists in the app installation directory if (!existsSync(templatePath)) { - throw new Error('PR template not found: ' + templatePath); + console.log(`šŸ” Template not found in application directory: ${templatePath}`); + console.log('āš ļø Using default template'); + return { isDefault: true, content: DEFAULT_TEMPLATE }; } - return templatePath; + console.log(`šŸ“‹ Using template from application directory: ${templatePath}`); + return { isDefault: false, path: templatePath }; } // Create PR using GitHub CLI -export async function createPR(title, body) { +export async function createPR(title, body, targetBranch = null) { try { - const command = `gh pr create --title "${title}" --body "${body.replace(/"/g, '\\"')}"`; + // Build the command with optional target branch + let command = `gh pr create --title "${title}" --body "${body.replace(/"/g, '\\"')}"`; + + // Add target branch if specified + if (targetBranch) { + command += ` --base "${targetBranch}"`; + } + const output = execSync(command).toString().trim(); return { success: true, @@ -179,15 +258,27 @@ export async function main() { } } - // Render template - const templatePath = getTemplatePath(); - - const renderedTemplate = await renderFileAsync(templatePath, { - ticket_number: ticketNumber || '', - changes, - has_tests: hasTests, - has_ticket: !!ticketNumber - }); + // Get template and render it + const template = getTemplatePath(); + + let renderedTemplate; + if (template.isDefault) { + // Render the default template string + renderedTemplate = twig.twig({ data: template.content }).render({ + ticket_number: ticketNumber || '', + changes, + has_tests: hasTests, + has_ticket: !!ticketNumber + }); + } else { + // Render from file + renderedTemplate = await renderFileAsync(template.path, { + ticket_number: ticketNumber || '', + changes, + has_tests: hasTests, + has_ticket: !!ticketNumber + }); + } console.log('\nšŸ“‹ PR Preview:'); console.log(`Title: ${ticketNumber ? `[${ticketNumber}] ` : ''}${prTitle}`); @@ -223,9 +314,26 @@ export async function main() { console.log(`āœ… Branch '${currentBranch}' successfully pushed to remote.`); } - // Create PR + const defaultBranch = getDefaultBranch(); + // Default branch gets added to the start of the array. + const remoteBranches = getRemoteBranches() + .sort((branchA, branchB) => { + return (branchA === defaultBranch) ? -1 : (branchB === defaultBranch) ? 1 : branchA.localeCompare(branchB); + }) + .map(branch => ({ title: branch, value: branch })); + // Ask user which branch to target for PR + // Default to the repository's default branch + console.log('\n🌿 Select target branch for PR:'); + const targetBranch = await search({ + message: 'šŸŽÆ Target branch for PR:', + default: defaultBranch, + source: (input = '') => { return remoteBranches.filter(branch => branch.title.includes(input)); }, + }); + + console.log(`šŸ“Œ Creating PR targeting branch: ${targetBranch}`); + const fullTitle = ticketNumber ? `[${ticketNumber}] ${prTitle}` : prTitle; - const result = await createPR(fullTitle, renderedTemplate); + const result = await createPR(fullTitle, renderedTemplate, targetBranch); if (result.success) { console.log(`\nāœ… Pull Request created successfully: ${result.url}`); diff --git a/jest.config.mjs b/jest.config.mjs index 43cea5e..48e8a98 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -1,6 +1,6 @@ export default { testEnvironment: 'node', - testMatch: ['**/test/basic.test.js'], + testMatch: ['**/test/*.test.js'], collectCoverage: true, coverageDirectory: 'coverage', coverageReporters: ['text', 'lcov'], diff --git a/package-lock.json b/package-lock.json index ac0b657..279421b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,9 @@ "@inquirer/prompts": "^7.5.0", "twig": "^1.17.1" }, + "bin": { + "gh-pr": "index.js" + }, "devDependencies": { "@eslint/js": "^9.24.0", "eslint": "^9.25.1", diff --git a/package.json b/package.json index 6a1f973..cfbfc7f 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "scripts": { "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "lint": "eslint .", + "lint:fix": "eslint . --fix", "start": "node index.js" }, "dependencies": { diff --git a/test/basic.test.js b/test/basic.test.js index 665f10d..ceb2326 100644 --- a/test/basic.test.js +++ b/test/basic.test.js @@ -25,7 +25,13 @@ describe('GitHub PR Maker', () => { test('Template path is correctly resolved', () => { // This test only verifies the path structure, not actual file existence - expect(getTemplatePath().endsWith('PULL_REQUEST_TEMPLATE.twig')).toBe(true); + const template = getTemplatePath(); + // getTemplatePath returns an object with either { isDefault: true, content } or { isDefault: false, path } + if (template.isDefault) { + expect(template.content).toBeDefined(); + } else { + expect(template.path.endsWith('PULL_REQUEST_TEMPLATE.twig')).toBe(true); + } }); test('PR title formatting with and without ticket number', () => { diff --git a/test/git.test.js b/test/git.test.js deleted file mode 100644 index 9adeb07..0000000 --- a/test/git.test.js +++ /dev/null @@ -1,125 +0,0 @@ -// @ts-check -import { execSync } from 'child_process'; - -// Mock child_process -jest.mock('child_process', () => ({ - execSync: jest.fn() -})); - -// Import our functions -import { getRecentCommits, checkGitRepository, checkGhCli } from '../index'; - -describe('Git utility functions', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('getRecentCommits', () => { - it('should parse git log output correctly', () => { - // Mock the git log output - execSync.mockReturnValueOnce(Buffer.from( - 'abc123|||Fix navigation bar|||Fixed styling issues in the navigation bar\n' + - 'def456|||Add user authentication|||Implemented JWT authentication\n' + - 'ghi789|||Update README|||Updated installation instructions' - )); - - const commits = getRecentCommits(3); - - // Verify the command that was executed - expect(execSync).toHaveBeenCalledWith('git log -3 --pretty=format:%h|||%s|||%b'); - - // Verify the parsed output - expect(commits).toEqual([ - { - hash: 'abc123', - subject: 'Fix navigation bar', - body: 'Fixed styling issues in the navigation bar' - }, - { - hash: 'def456', - subject: 'Add user authentication', - body: 'Implemented JWT authentication' - }, - { - hash: 'ghi789', - subject: 'Update README', - body: 'Updated installation instructions' - } - ]); - }); - - it('should handle commits with empty bodies', () => { - execSync.mockReturnValueOnce(Buffer.from( - 'abc123|||Fix bug|||\n' + - 'def456|||Add feature|||This is a description' - )); - - const commits = getRecentCommits(2); - - expect(commits).toEqual([ - { - hash: 'abc123', - subject: 'Fix bug', - body: '' - }, - { - hash: 'def456', - subject: 'Add feature', - body: 'This is a description' - } - ]); - }); - - it('should return empty array on error', () => { - execSync.mockImplementationOnce(() => { - throw new Error('git command failed'); - }); - - const commits = getRecentCommits(); - - expect(commits).toEqual([]); - }); - }); - - describe('checkGitRepository', () => { - it('should return true when in a git repo', () => { - execSync.mockReturnValueOnce(Buffer.from('true')); - - const result = checkGitRepository(); - - expect(execSync).toHaveBeenCalledWith('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }); - expect(result).toBe(true); - }); - - it('should return false when not in a git repo', () => { - execSync.mockImplementationOnce(() => { - throw new Error('not a git repository'); - }); - - const result = checkGitRepository(); - - expect(result).toBe(false); - }); - }); - - describe('checkGhCli', () => { - it('should return true when gh is installed', () => { - execSync.mockReturnValueOnce(Buffer.from('gh version 2.0.0')); - - const result = checkGhCli(); - - expect(execSync).toHaveBeenCalledWith('gh --version', { stdio: 'ignore' }); - expect(result).toBe(true); - }); - - it('should return false when gh is not installed', () => { - execSync.mockImplementationOnce(() => { - throw new Error('command not found: gh'); - }); - - const result = checkGhCli(); - - expect(result).toBe(false); - }); - }); -}); diff --git a/test/index.test.js b/test/index.test.js deleted file mode 100644 index 0ae0c4b..0000000 --- a/test/index.test.js +++ /dev/null @@ -1,156 +0,0 @@ -import { execSync } from 'child_process'; - -jest.mock('child_process', () => ({ - execSync: jest.fn() -})); - -jest.mock('fs', () => ({ - existsSync: jest.fn() -})); - -jest.mock('@inquirer/prompts', () => ({ - input: jest.fn(), - confirm: jest.fn() -})); - -jest.mock('twig', () => ({ - default: { - renderFile: jest.fn() - } -})); - -// Import our functions after mocking -const { getRecentCommits, checkGitRepository, checkGhCli, createPR } = require('../index.js'); - -describe('Git utility functions', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('getRecentCommits', () => { - it('should parse git log output correctly', () => { - // Mock the git log output - execSync.mockReturnValueOnce(Buffer.from( - 'abc123|||Fix navigation bar|||Fixed styling issues\n' + - 'def456|||Add user auth|||Implemented JWT auth' - )); - - const commits = getRecentCommits(2); - - expect(execSync).toHaveBeenCalledWith('git log -2 --pretty=format:%h|||%s|||%b'); - expect(commits).toEqual([ - { - hash: 'abc123', - subject: 'Fix navigation bar', - body: 'Fixed styling issues' - }, - { - hash: 'def456', - subject: 'Add user auth', - body: 'Implemented JWT auth' - } - ]); - }); - - it('should handle commits with empty bodies', () => { - execSync.mockReturnValueOnce(Buffer.from( - 'abc123|||Fix bug|||\n' + - 'def456|||Add feature|||This is a description' - )); - - const commits = getRecentCommits(2); - - expect(commits).toEqual([ - { - hash: 'abc123', - subject: 'Fix bug', - body: '' - }, - { - hash: 'def456', - subject: 'Add feature', - body: 'This is a description' - } - ]); - }); - - it('should return empty array on error', () => { - execSync.mockImplementationOnce(() => { - throw new Error('git command failed'); - }); - - const commits = getRecentCommits(); - - expect(commits).toEqual([]); - }); - }); - - describe('checkGitRepository', () => { - it('should return true when in a git repo', () => { - execSync.mockReturnValueOnce(Buffer.from('true')); - - const result = checkGitRepository(); - - expect(execSync).toHaveBeenCalledWith('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }); - expect(result).toBe(true); - }); - - it('should return false when not in a git repo', () => { - execSync.mockImplementationOnce(() => { - throw new Error('not a git repository'); - }); - - const result = checkGitRepository(); - - expect(result).toBe(false); - }); - }); - - describe('checkGhCli', () => { - it('should return true when gh is installed', () => { - execSync.mockReturnValueOnce(Buffer.from('gh version 2.0.0')); - - const result = checkGhCli(); - - expect(execSync).toHaveBeenCalledWith('gh --version', { stdio: 'ignore' }); - expect(result).toBe(true); - }); - - it('should return false when gh is not installed', () => { - execSync.mockImplementationOnce(() => { - throw new Error('command not found: gh'); - }); - - const result = checkGhCli(); - - expect(result).toBe(false); - }); - }); - - describe('createPR', () => { - it('should create a PR successfully', async () => { - execSync.mockReturnValueOnce(Buffer.from('https://github.com/user/repo/pull/123')); - - const result = await createPR('Test PR', 'PR body'); - - expect(execSync).toHaveBeenCalledWith('gh pr create --title "Test PR" --body "PR body"'); - expect(result).toEqual({ - success: true, - url: 'https://github.com/user/repo/pull/123' - }); - }); - - it('should handle PR creation errors', async () => { - execSync.mockImplementationOnce(() => { - throw new Error('Failed to create PR'); - }); - - const result = await createPR('Test PR', 'PR body'); - - expect(result).toEqual({ - success: false, - error: 'Failed to create PR' - }); - }); - }); -}); diff --git a/test/template.test.js b/test/template.test.js deleted file mode 100644 index 957ab20..0000000 --- a/test/template.test.js +++ /dev/null @@ -1,45 +0,0 @@ -import { existsSync } from 'fs'; -import path from 'path'; - -jest.mock('fs', () => ({ - existsSync: jest.fn() -})); - -jest.mock('twig', () => ({ - default: { - renderFile: jest.fn((templatePath, data, callback) => { - // Simple mock implementation to verify template engine integration - const rendered = `Rendered template with ticket ${data.ticket_number}`; - callback(null, rendered); - }) - } -})); - -// Import our function after mocking -const { getTemplatePath } = require('../index.js'); - -describe('Template functions', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('getTemplatePath', () => { - it('should return the template path when it exists', () => { - // Set up fs mock to return true for existsSync - existsSync.mockReturnValueOnce(true); - - const templatePath = getTemplatePath(); - const expectedPath = path.join(process.cwd(), 'templates', 'PULL_REQUEST_TEMPLATE.twig'); - - expect(existsSync).toHaveBeenCalledWith(expectedPath); - expect(templatePath).toBe(expectedPath); - }); - - it('should throw an error when template does not exist', () => { - // Set up fs mock to return false for existsSync - existsSync.mockReturnValueOnce(false); - - expect(() => getTemplatePath()).toThrow('PR template not found'); - }); - }); -});