Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**/.claude/settings.local.json
144 changes: 126 additions & 18 deletions index.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 [];
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Check if branch is pushed to remote
export function isBranchPushedToRemote(branchName) {
try {
Expand Down Expand Up @@ -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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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,
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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)); },
});
Comment on lines +317 to +331

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify search prompt source function return type.

The search prompt implementation looks good, but ensure the source function returns the correct type. The @inquirer/prompts search expects the source function to return a Promise or array directly:

 const targetBranch = await search({
   message: '🎯 Target branch for PR:',
   default: defaultBranch,
-  source: (input = '') => { return remoteBranches.filter(branch => branch.title.includes(input)); },
+  source: (input = '') => Promise.resolve(remoteBranches.filter(branch => branch.title.includes(input))),
 });

Or verify that the current synchronous return is supported by checking the latest documentation.


🌐 Web query:

Does @inquirer/prompts search source function support synchronous array returns or does it require Promise?

💡 Result:

The source function for the search prompt in @inquirer/prompts must return a Promise; synchronous array returns are not supported[1]. The official documentation specifies the signature as:

function(term: string | void, opt: { signal: AbortSignal }): Promise<ReadonlyArray<Choice<Value> | Separator>>;

[1]

This means:

  • The function you pass to source should always return a Promise that resolves to the array of choices[1].
  • If you return a plain array synchronously (rather than a Promise that resolves to an array), it will not work as expected[1].

This requirement exists to support asynchronous data sources (like network requests) and to ensure prompt behavior is consistent and non-blocking for all use cases[1][7]. A typical usage, even for synchronous data, should still return a promise (e.g., Promise.resolve([...])).

In summary:

  • source must return a Promise (even if your data is static/synchronous, wrap it in a Promise)[1][7].
  • Direct synchronous returns (plain arrays) are not supported.

Example for synchronous values (correct approach):

source: async (term, { signal }) => {
  // Sync logic
  return [{ name: 'a', value: 'a' }, { name: 'b', value: 'b' }];
}
// OR
source: (term, { signal }) => Promise.resolve([
  { name: 'a', value: 'a' }, { name: 'b', value: 'b' }
]);

[1]

Returning a plain array (not wrapped in a Promise) will not match the expected contract and can cause runtime errors[1].

Citations:


Wrap source return in a Promise for @inquirer/prompts

The search prompt’s source function must return a Promise (synchronous arrays aren’t supported). Update your handler in index.js accordingly.

Files needing update:

  • index.js (around lines 317–331)

Proposed change:

 const targetBranch = await search({
   message: '🎯 Target branch for PR:',
   default: defaultBranch,
-  source: (input = '') => {
-    return remoteBranches.filter(branch => branch.title.includes(input));
-  },
+  // Must return a Promise per @inquirer/prompts signature
+  source: (input = '', { signal }) =>
+    Promise.resolve(
+      remoteBranches.filter(branch => branch.title.includes(input))
+    ),
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)); },
});
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,
// Must return a Promise per @inquirer/prompts signature
source: (input = '', { signal }) =>
Promise.resolve(
remoteBranches.filter(branch => branch.title.includes(input))
),
});
🤖 Prompt for AI Agents
In index.js around lines 317 to 331, the source function passed to the search
prompt must return a Promise instead of a synchronous array. Modify the source
function to return a Promise that resolves with the filtered remoteBranches
array. This can be done by wrapping the filter operation inside a
Promise.resolve call or by making the function async and returning the filtered
array directly.


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}`);
Expand Down
2 changes: 1 addition & 1 deletion jest.config.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export default {
testEnvironment: 'node',
testMatch: ['**/test/basic.test.js'],
testMatch: ['**/test/*.test.js'],
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov'],
Expand Down
3 changes: 3 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 7 additions & 1 deletion test/basic.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update comment to reflect expanded test scope.

The comment states the test "only verifies the path structure," but the test now also validates template.content when using the default inline template.

Apply this diff to clarify:

-    // This test only verifies the path structure, not actual file existence
+    // This test verifies the template structure (path or default content), not actual file existence
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// This test only verifies the path structure, not actual file existence
// This test verifies the template structure (path or default content), not actual file existence
🤖 Prompt for AI Agents
In test/basic.test.js around line 27, the inline comment incorrectly states the
test "only verifies the path structure" but the test also asserts
template.content for the default inline template; update the comment to
accurately describe that the test validates both the path structure and the
inline template content (i.e., it checks filename/path structure and that
template.content matches the default inline template).

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', () => {
Expand Down
125 changes: 0 additions & 125 deletions test/git.test.js

This file was deleted.

Loading