diff --git a/README.md b/README.md index a39b756..4b65992 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ venfork status - Check which remotes are configured - See your current branch -### `venfork stage ` +### `venfork stage [--create-pr] [--copy-pr-body]` Push a branch to the public fork, making it visible and ready for PR to upstream. @@ -250,13 +250,17 @@ Push a branch to the public fork, making it visible and ready for PR to upstream ```bash venfork stage feature-auth venfork stage bugfix/issue-123 +venfork stage feature-auth --create-pr +venfork stage feature-auth --create-pr --copy-pr-body ``` **What it does:** 1. Verifies branch exists 2. Shows staging details and confirmation 3. Pushes to public fork -4. Provides PR creation link +4. Optionally creates a draft PR to upstream (`--create-pr`) +5. Optionally copies title/body from private origin PR (`--copy-pr-body`, requires `--create-pr`) +6. Provides PR creation link ## Environment Variables diff --git a/src/commands.ts b/src/commands.ts index 3c44a26..e7734ed 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -59,6 +59,44 @@ async function ensureVenforkRemotes( await $({ cwd })`git remote set-url --push upstream DISABLE`; } +async function getPrivateOriginPrForBranch( + branch: string +): Promise<{ title: string; body: string } | null> { + const originResult = await $({ + reject: false, + })`git remote get-url origin`; + if (originResult.exitCode !== 0) { + return null; + } + + const originUrl = originResult.stdout.trim(); + const originRepoPath = parseRepoPath(originUrl); + const originOwner = parseOwner(originUrl); + if (!originRepoPath || !originOwner) { + return null; + } + + const prsResult = await $({ + reject: false, + })`gh pr list --repo ${originRepoPath} --head ${`${originOwner}:${branch}`} --state open --limit 1 --json title,body`; + if (prsResult.exitCode !== 0) { + return null; + } + + try { + const prs = JSON.parse(prsResult.stdout) as Array<{ + title: string; + body: string; + }>; + if (!prs.length) { + return null; + } + return prs[0]; + } catch { + return null; + } +} + /** * Setup command: Create private mirror and public fork * @@ -705,7 +743,10 @@ To force sync anyway: git push origin upstream/${defaultBranch}:${defaultBranch} /** * Stage command: Push branch to public fork for PR to upstream */ -export async function stageCommand(branch: string): Promise { +export async function stageCommand( + branch: string, + options?: { createPr?: boolean; copyPrBody?: boolean } +): Promise { p.intro('📤 Venfork Stage'); // Check GitHub CLI authentication @@ -716,7 +757,7 @@ export async function stageCommand(branch: string): Promise { if (!branch) { p.log.error('Branch name is required'); - p.outro('Usage: venfork stage '); + p.outro('Usage: venfork stage [--create-pr] [--copy-pr-body]'); process.exit(1); } @@ -742,6 +783,23 @@ export async function stageCommand(branch: string): Promise { } const publicUrl = publicUrlResult.stdout.trim(); const publicRepoPath = parseRepoPath(publicUrl); + const publicOwner = parseOwner(publicUrl); + + if (!publicRepoPath) { + p.log.error( + "Could not determine the GitHub repository path from the 'public' remote URL. " + + "Please ensure the 'public' remote points to a valid github.com repository.", + ); + process.exit(1); + } + + if (options?.createPr && !publicOwner) { + p.log.error( + "Could not determine the GitHub owner from the 'public' remote URL required for --create-pr. " + + "Please ensure the 'public' remote points to a valid github.com repository.", + ); + process.exit(1); + } // Step 3: Get upstream URL for PR link const upstreamUrlResult = await $({ @@ -786,9 +844,43 @@ This makes your work visible and ready for PR to upstream. // Step 7: Show PR creation link const prUrl = `https://github.com/${upstreamRepoPath}/compare/${upstreamDefaultBranch}...${publicRepoPath.split('/')[0]}:${branch}?expand=1`; + let createdPrUrl: string | null = null; + + const shouldCreatePr = options?.createPr ?? false; + const shouldCopyPrBody = options?.copyPrBody ?? false; + if (shouldCopyPrBody && !shouldCreatePr) { + throw new Error('--copy-pr-body requires --create-pr'); + } + + if (shouldCreatePr) { + s.start('Creating draft PR to upstream'); + const copied = shouldCopyPrBody + ? await getPrivateOriginPrForBranch(branch) + : null; + const title = copied?.title || `Stage ${branch} for upstream`; + const body = + copied?.body || + `Staged from private mirror branch \`${branch}\` via \`venfork stage --create-pr\`.`; + + const createResult = await $({ + reject: false, + })`gh pr create --repo ${upstreamRepoPath} --head ${`${publicOwner}:${branch}`} --base ${upstreamDefaultBranch} --title ${title} --body ${body} --draft`; + if (createResult.exitCode === 0) { + createdPrUrl = createResult.stdout.trim() || null; + s.stop('Draft PR created'); + } else { + s.stop('Draft PR not created'); + const stderr = createResult.stderr?.toString().trim(); + if (stderr) { + p.log.warn(`gh pr create failed: ${stderr}`); + } + } + } p.note( - `Your branch is now on the public fork!\n\nCreate a pull request to upstream:\n ${prUrl}`, + createdPrUrl + ? `Your branch is now on the public fork!\n\nDraft PR created:\n ${createdPrUrl}\n\nCompare URL:\n ${prUrl}` + : `Your branch is now on the public fork!\n\nCreate a pull request to upstream:\n ${prUrl}`, 'Next Steps' ); @@ -907,8 +999,10 @@ venfork sync [branch] Update default branches of origin and public to match upstream Syncs main/master branch without affecting your current work -venfork stage +venfork stage [--create-pr] [--copy-pr-body] Push branch to public fork for PR to upstream + Optional: create draft upstream PR automatically + Optional: copy title/body from private origin PR when creating PR This is when your work becomes visible to the client`, 'Available Commands' ); diff --git a/src/index.ts b/src/index.ts index 9455561..5b3a80d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { syncCommand, } from './commands.js'; import { parseSetupCliArgs } from './setup-args.js'; +import { parseStageCliArgs } from './stage-args.js'; /** * Main CLI entry point @@ -45,9 +46,14 @@ async function main(): Promise { case 'sync': await syncCommand(args[1]); break; - case 'stage': - await stageCommand(args[1]); + case 'stage': { + const parsed = parseStageCliArgs(args.slice(1)); + await stageCommand(parsed.branch || '', { + createPr: parsed.createPr, + copyPrBody: parsed.copyPrBody, + }); break; + } case 'status': await statusCommand(); break; diff --git a/src/stage-args.ts b/src/stage-args.ts new file mode 100644 index 0000000..d6281ff --- /dev/null +++ b/src/stage-args.ts @@ -0,0 +1,36 @@ +export type ParsedStageArgs = { + branch?: string; + createPr: boolean; + copyPrBody: boolean; +}; + +/** + * Parse `venfork stage ...` argv after the `stage` token. + */ +export function parseStageCliArgs(stageArgs: string[]): ParsedStageArgs { + const positional: string[] = []; + let createPr = false; + let copyPrBody = false; + + for (const arg of stageArgs) { + if (arg === '--create-pr') { + createPr = true; + continue; + } + if (arg === '--copy-pr-body') { + copyPrBody = true; + continue; + } + positional.push(arg); + } + + if (copyPrBody && !createPr) { + throw new Error('--copy-pr-body requires --create-pr'); + } + + return { + branch: positional[0], + createPr, + copyPrBody, + }; +} diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 95de06a..cd3f33a 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -503,6 +503,48 @@ describe('stageCommand', () => { // Process.exit should have been called expect(process.exit).toHaveBeenCalled(); }); + + test('creates draft PR when createPr option is enabled', async () => { + try { + await stageCommand('feature-branch', { createPr: true }); + } catch { + // Expected in mocked environment + } + + expect(execaCalls.some((cmd) => cmd.includes('gh pr create --repo'))).toBe( + true + ); + expect(execaCalls.some((cmd) => cmd.includes('--draft'))).toBe(true); + }); + + test('copies private PR body when copyPrBody is enabled', async () => { + mockResponses.set( + 'gh pr list --repo test/repo --head test:feature-branch', + { + exitCode: 0, + stdout: JSON.stringify([ + { title: 'Internal PR title', body: 'Internal PR body' }, + ]), + stderr: '', + } + ); + + try { + await stageCommand('feature-branch', { + createPr: true, + copyPrBody: true, + }); + } catch { + // Expected in mocked environment + } + + expect( + execaCalls.some((cmd) => cmd.includes('gh pr list --repo test/repo')) + ).toBe(true); + expect( + execaCalls.some((cmd) => cmd.includes('--title Internal PR title')) + ).toBe(true); + }); }); describe('statusCommand', () => { diff --git a/tests/stage-args.test.ts b/tests/stage-args.test.ts new file mode 100644 index 0000000..1ea943e --- /dev/null +++ b/tests/stage-args.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test'; +import { parseStageCliArgs } from '../src/stage-args.js'; + +describe('parseStageCliArgs', () => { + test('parses branch-only invocation', () => { + expect(parseStageCliArgs(['feature/x'])).toEqual({ + branch: 'feature/x', + createPr: false, + copyPrBody: false, + }); + }); + + test('parses create-pr and copy-pr-body flags', () => { + expect( + parseStageCliArgs(['feature/x', '--create-pr', '--copy-pr-body']) + ).toEqual({ + branch: 'feature/x', + createPr: true, + copyPrBody: true, + }); + }); + + test('throws when copy-pr-body used without create-pr', () => { + expect(() => parseStageCliArgs(['feature/x', '--copy-pr-body'])).toThrow( + '--copy-pr-body requires --create-pr' + ); + }); +});