-
Notifications
You must be signed in to change notification settings - Fork 0
fix(stage): validate public remote URL parsing and surface gh pr create stderr #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<void> { | ||||||||||||||
| export async function stageCommand( | ||||||||||||||
| branch: string, | ||||||||||||||
| options?: { createPr?: boolean; copyPrBody?: boolean } | ||||||||||||||
| ): Promise<void> { | ||||||||||||||
| p.intro('📤 Venfork Stage'); | ||||||||||||||
|
|
||||||||||||||
| // Check GitHub CLI authentication | ||||||||||||||
|
|
@@ -716,7 +757,7 @@ export async function stageCommand(branch: string): Promise<void> { | |||||||||||||
|
|
||||||||||||||
| if (!branch) { | ||||||||||||||
| p.log.error('Branch name is required'); | ||||||||||||||
| p.outro('Usage: venfork stage <branch>'); | ||||||||||||||
| p.outro('Usage: venfork stage <branch> [--create-pr] [--copy-pr-body]'); | ||||||||||||||
| process.exit(1); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -742,6 +783,23 @@ export async function stageCommand(branch: string): Promise<void> { | |||||||||||||
| } | ||||||||||||||
| 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'); | ||||||||||||||
|
||||||||||||||
| s.stop('Draft PR not created'); | |
| s.stop('Draft PR not created'); | |
| const stderr = createResult.stderr?.toString().trim(); | |
| if (stderr) { | |
| p.log.warn(`gh pr create failed: ${stderr}`); | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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' | ||
| ); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
publicRepoPath/publicOwnerare used to build the compare URL and to setgh pr create --head, but neither value is validated. If thepublicremote URL isn’t a parseable github.com URL,parseRepoPath/parseOwnerwill return an empty string and--head ${publicOwner}:${branch}will be malformed. Add validation (e.g., if!publicRepoPathor (when--create-pr)!publicOwner, fail with a clear error) before constructing URLs/commands.