diff --git a/README.md b/README.md index 6223850..ccc28f0 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/src/core/PRCreator.ts b/src/core/PRCreator.ts index 6cc46a6..76f7df7 100644 --- a/src/core/PRCreator.ts +++ b/src/core/PRCreator.ts @@ -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 { - 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); } @@ -30,43 +33,102 @@ export class PRCreator { process.exit(1); } - await this.createSinglePR(targetBranch, tool); + await this.createPRWithRemote(targetBranch, remoteInfo); } } - private async detectPRTool(): Promise { + private async getRemoteInfo(): Promise { 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 { + private async createPRWithRemote(branch: string, remoteInfo: RemoteInfo): Promise { 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 }> {