Skip to content

Commit 172e38d

Browse files
authored
Feature: Allow selecting target branch for PR (#8)
2 parents 3c21550 + 7774479 commit 172e38d

File tree

9 files changed

+139
-346
lines changed

9 files changed

+139
-346
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
**/.claude/settings.local.json

index.js

Lines changed: 126 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env node
22

33
import { execSync } from 'child_process';
4-
import { input, confirm } from '@inquirer/prompts';
4+
import { input, confirm, search } from '@inquirer/prompts';
55
import twig from 'twig';
66
import { promisify } from 'util';
77
import { existsSync } from 'fs';
@@ -54,6 +54,44 @@ export function getCurrentBranch() {
5454
}
5555
}
5656

57+
// Get default branch name
58+
export function getDefaultBranch() {
59+
try {
60+
// Try to get the remote's default branch
61+
// First get the default remote (usually origin)
62+
const remote = execSync('git remote').toString().trim().split('\n')[0];
63+
64+
// Then get the default branch (what HEAD points to)
65+
const output = execSync(`git remote show ${remote} | grep "HEAD branch"`).toString().trim();
66+
const match = output.match(/HEAD branch:\s*(.+)$/);
67+
68+
if (match && match[1]) {
69+
return match[1];
70+
}
71+
72+
// Fallback to 'main' or 'master' if we can't determine it
73+
return 'main';
74+
} catch {
75+
// Fallback to a sensible default
76+
return 'main';
77+
}
78+
}
79+
80+
// Get list of remote branches
81+
export function getRemoteBranches() {
82+
try {
83+
// Get all remote branches, excluding HEAD reference
84+
const output = execSync('git branch -r | grep -v HEAD').toString().trim();
85+
86+
// Parse and clean branch names
87+
return output.split('\n')
88+
.map(branch => branch.trim().replace(/^origin\//, ''))
89+
.filter(branch => branch !== '');
90+
} catch {
91+
return [];
92+
}
93+
}
94+
5795
// Check if branch is pushed to remote
5896
export function isBranchPushedToRemote(branchName) {
5997
try {
@@ -86,21 +124,62 @@ export function checkGhCli() {
86124
}
87125
}
88126

89-
// Get the template path
127+
// Default PR template content as a fallback
128+
const DEFAULT_TEMPLATE = `{% if has_ticket %}
129+
## Ticket
130+
{{ ticket_number }}
131+
{% endif %}
132+
133+
## Changes
134+
{% for change in changes %}
135+
- {{ change }}
136+
{% endfor %}
137+
138+
{% if has_tests %}
139+
## Tests
140+
- ✅ Includes tests
141+
{% else %}
142+
## Tests
143+
- ❌ No tests included
144+
{% endif %}
145+
`;
146+
147+
// Get the directory where the script is installed
148+
function getScriptDir() {
149+
// Use import.meta.url to get the full URL of the current module
150+
const fileUrl = import.meta.url;
151+
// Convert the file URL to a system path and get the directory
152+
return path.dirname(new URL(fileUrl).pathname);
153+
}
154+
155+
// Get the template path or create default template
90156
export function getTemplatePath() {
91-
const templatePath = path.join(__dirname, 'templates', 'PULL_REQUEST_TEMPLATE.twig');
157+
// Get template ONLY from the script's installation directory
158+
const scriptDir = getScriptDir();
159+
const templatePath = path.join(scriptDir, 'templates', 'PULL_REQUEST_TEMPLATE.twig');
92160

161+
// Check if template exists in the app installation directory
93162
if (!existsSync(templatePath)) {
94-
throw new Error('PR template not found: ' + templatePath);
163+
console.log(`🔍 Template not found in application directory: ${templatePath}`);
164+
console.log('⚠️ Using default template');
165+
return { isDefault: true, content: DEFAULT_TEMPLATE };
95166
}
96167

97-
return templatePath;
168+
console.log(`📋 Using template from application directory: ${templatePath}`);
169+
return { isDefault: false, path: templatePath };
98170
}
99171

100172
// Create PR using GitHub CLI
101-
export async function createPR(title, body) {
173+
export async function createPR(title, body, targetBranch = null) {
102174
try {
103-
const command = `gh pr create --title "${title}" --body "${body.replace(/"/g, '\\"')}"`;
175+
// Build the command with optional target branch
176+
let command = `gh pr create --title "${title}" --body "${body.replace(/"/g, '\\"')}"`;
177+
178+
// Add target branch if specified
179+
if (targetBranch) {
180+
command += ` --base "${targetBranch}"`;
181+
}
182+
104183
const output = execSync(command).toString().trim();
105184
return {
106185
success: true,
@@ -179,15 +258,27 @@ export async function main() {
179258
}
180259
}
181260

182-
// Render template
183-
const templatePath = getTemplatePath();
184-
185-
const renderedTemplate = await renderFileAsync(templatePath, {
186-
ticket_number: ticketNumber || '',
187-
changes,
188-
has_tests: hasTests,
189-
has_ticket: !!ticketNumber
190-
});
261+
// Get template and render it
262+
const template = getTemplatePath();
263+
264+
let renderedTemplate;
265+
if (template.isDefault) {
266+
// Render the default template string
267+
renderedTemplate = twig.twig({ data: template.content }).render({
268+
ticket_number: ticketNumber || '',
269+
changes,
270+
has_tests: hasTests,
271+
has_ticket: !!ticketNumber
272+
});
273+
} else {
274+
// Render from file
275+
renderedTemplate = await renderFileAsync(template.path, {
276+
ticket_number: ticketNumber || '',
277+
changes,
278+
has_tests: hasTests,
279+
has_ticket: !!ticketNumber
280+
});
281+
}
191282

192283
console.log('\n📋 PR Preview:');
193284
console.log(`Title: ${ticketNumber ? `[${ticketNumber}] ` : ''}${prTitle}`);
@@ -223,9 +314,26 @@ export async function main() {
223314
console.log(`✅ Branch '${currentBranch}' successfully pushed to remote.`);
224315
}
225316

226-
// Create PR
317+
const defaultBranch = getDefaultBranch();
318+
// Default branch gets added to the start of the array.
319+
const remoteBranches = getRemoteBranches()
320+
.sort((branchA, branchB) => {
321+
return (branchA === defaultBranch) ? -1 : (branchB === defaultBranch) ? 1 : branchA.localeCompare(branchB);
322+
})
323+
.map(branch => ({ title: branch, value: branch }));
324+
// Ask user which branch to target for PR
325+
// Default to the repository's default branch
326+
console.log('\n🌿 Select target branch for PR:');
327+
const targetBranch = await search({
328+
message: '🎯 Target branch for PR:',
329+
default: defaultBranch,
330+
source: (input = '') => { return remoteBranches.filter(branch => branch.title.includes(input)); },
331+
});
332+
333+
console.log(`📌 Creating PR targeting branch: ${targetBranch}`);
334+
227335
const fullTitle = ticketNumber ? `[${ticketNumber}] ${prTitle}` : prTitle;
228-
const result = await createPR(fullTitle, renderedTemplate);
336+
const result = await createPR(fullTitle, renderedTemplate, targetBranch);
229337

230338
if (result.success) {
231339
console.log(`\n✅ Pull Request created successfully: ${result.url}`);

jest.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export default {
22
testEnvironment: 'node',
3-
testMatch: ['**/test/basic.test.js'],
3+
testMatch: ['**/test/*.test.js'],
44
collectCoverage: true,
55
coverageDirectory: 'coverage',
66
coverageReporters: ['text', 'lcov'],

package-lock.json

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"scripts": {
1111
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
1212
"lint": "eslint .",
13+
"lint:fix": "eslint . --fix",
1314
"start": "node index.js"
1415
},
1516
"dependencies": {

test/basic.test.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,13 @@ describe('GitHub PR Maker', () => {
2525

2626
test('Template path is correctly resolved', () => {
2727
// This test only verifies the path structure, not actual file existence
28-
expect(getTemplatePath().endsWith('PULL_REQUEST_TEMPLATE.twig')).toBe(true);
28+
const template = getTemplatePath();
29+
// getTemplatePath returns an object with either { isDefault: true, content } or { isDefault: false, path }
30+
if (template.isDefault) {
31+
expect(template.content).toBeDefined();
32+
} else {
33+
expect(template.path.endsWith('PULL_REQUEST_TEMPLATE.twig')).toBe(true);
34+
}
2935
});
3036

3137
test('PR title formatting with and without ticket number', () => {

test/git.test.js

Lines changed: 0 additions & 125 deletions
This file was deleted.

0 commit comments

Comments
 (0)