Skip to content
Open
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Aviator-inspired CLI tool for managing stacked git branches, built with TypeScri
- **Dynamic Branch Stacking**: Metadata stored in `.git/config`, no hardcoded branches
- **Aviator-style Commands**: Clean, intuitive API inspired by aviator library
- **Smart Git Operations**: Uses simple-git for reliable git operations
- **Automatic PR Creation**: Works with GitHub CLI (gh) and GitLab CLI (glab)
- **Automatic PR Creation**: Works with GitHub CLI (gh), GitLab CLI (glab), and Bitbucket CLI (bb)
- **Stack Updates**: Rebase entire stacks with conflict detection
- **Status Display**: Rich status view with sync information

Expand Down Expand Up @@ -95,7 +95,7 @@ pnpm run test

- Node.js >= 16.0.0
- Git repository
- GitHub CLI (gh) or GitLab CLI (glab) for PR creation
- GitHub CLI (gh), GitLab CLI (glab), or Bitbucket CLI (bb) for PR creation

## Architecture

Expand Down
114 changes: 88 additions & 26 deletions src/core/PRCreator.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { SimpleGit } from 'simple-git';
import { Logger } from '../utils/Logger.js';
import { GitConfig } from './GitConfig.js';
import { exec } from 'child_process';
import { promisify } from 'util';

const execAsync = promisify(exec);
interface RemoteInfo {
type: 'github' | 'gitlab' | 'bitbucket' | 'unknown';
owner: string;
repo: string;
baseUrl: string;
}

export class PRCreator {
constructor(private git: SimpleGit, private logger: Logger, private gitConfig: GitConfig) {}

async createPullRequests(branch?: string, all?: boolean, _current?: boolean): Promise<void> {
const tool = await this.detectPRTool();
if (!tool) {
this.logger.error('GitHub CLI (gh) or GitLab CLI (glab) required for PR creation');
const remoteInfo = await this.getRemoteInfo();
if (!remoteInfo) {
this.logger.error('Could not determine remote repository type. Supported: GitHub, GitLab, Bitbucket');
process.exit(1);
}

Expand All @@ -30,43 +33,102 @@ export class PRCreator {
process.exit(1);
}

await this.createSinglePR(targetBranch, tool);
await this.createPRWithRemote(targetBranch, remoteInfo);
}
}

private async detectPRTool(): Promise<string | null> {
private async getRemoteInfo(): Promise<RemoteInfo | null> {
try {
await execAsync('gh --version');
return 'gh';
} catch {
try {
await execAsync('glab --version');
return 'glab';
} catch {
const remotes = await this.git.getRemotes(true);
const origin = remotes.find(r => r.name === 'origin');

if (!origin?.refs?.push) {
return null;
}

const url = origin.refs.push;

// GitHub
if (url.includes('github.com')) {
const match = url.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/);
if (match) {
return {
type: 'github',
owner: match[1],
repo: match[2],
baseUrl: 'https://github.com'
};
}
}

// GitLab
if (url.includes('gitlab.com')) {
const match = url.match(/gitlab\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/);
if (match) {
return {
type: 'gitlab',
owner: match[1],
repo: match[2],
baseUrl: 'https://gitlab.com'
};
}
}

// Bitbucket
if (url.includes('bitbucket.org')) {
const match = url.match(/bitbucket\.org[:/]([^/]+)\/(.+?)(?:\.git)?$/);
if (match) {
return {
type: 'bitbucket',
owner: match[1],
repo: match[2],
baseUrl: 'https://bitbucket.org'
};
}
}

return null;
} catch {
return null;
}
}

private async createSinglePR(branch: string, tool: string): Promise<void> {
private async createPRWithRemote(branch: string, remoteInfo: RemoteInfo): Promise<void> {
const parent = await this.getParentBranch(branch);

this.logger.info(`Creating PR for branch '${branch}' (base: '${parent}')`);

const { title, body } = await this.generatePRContent(branch, parent);

// First, push the branch to remote
try {
if (tool === 'gh') {
await execAsync(`gh pr create --title "${title}" --body "${body}" --base "${parent}" --head "${branch}"`);
this.logger.success(`✓ Created PR for '${branch}'`);
} else if (tool === 'glab') {
await execAsync(`glab mr create --title "${title}" --description "${body}" --target-branch "${parent}" --source-branch "${branch}"`);
this.logger.success(`✓ Created MR for '${branch}'`);
}
await this.git.push(['origin', branch]);
this.logger.info(`✓ Pushed '${branch}' to remote`);
} catch (error) {
this.logger.error(`✗ Failed to create PR for '${branch}': ${error}`);
this.logger.error(`✗ Failed to push '${branch}': ${error}`);
throw error;
}

const { title } = await this.generatePRContent(branch, parent);

// Generate PR URL based on remote type
let prUrl = '';
const encodedTitle = encodeURIComponent(title);

switch (remoteInfo.type) {
case 'github':
prUrl = `${remoteInfo.baseUrl}/${remoteInfo.owner}/${remoteInfo.repo}/compare/${parent}...${branch}?quick_pull=1&title=${encodedTitle}`;
break;
case 'gitlab':
prUrl = `${remoteInfo.baseUrl}/${remoteInfo.owner}/${remoteInfo.repo}/-/merge_requests/new?merge_request[source_branch]=${branch}&merge_request[target_branch]=${parent}&merge_request[title]=${encodedTitle}`;
break;
case 'bitbucket':
prUrl = `${remoteInfo.baseUrl}/${remoteInfo.owner}/${remoteInfo.repo}/pull-requests/new?source=${branch}&dest=${parent}&title=${encodedTitle}`;
break;
}

this.logger.success(`✓ PR URL for '${branch}':`);
this.logger.info(prUrl);
this.logger.info('');
this.logger.info('Copy and paste the URL above into your browser to create the PR');
}

private async generatePRContent(branch: string, parent: string): Promise<{ title: string; body: string }> {
Expand Down