Thanks to visit codestin.com
Credit goes to github.com

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
135 changes: 131 additions & 4 deletions src/main/source-control/hosted-review-creation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ describe('createHostedReview', () => {
if (args[0] === 'log') {
return { stdout: '- Feature title\n', stderr: '' }
}
if (args[0] === 'rev-list' && args.includes('--count')) {
return { stdout: '1\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
createGitHubPullRequestMock.mockResolvedValue({
Expand Down Expand Up @@ -153,6 +156,41 @@ describe('createHostedReview', () => {
expect(createGitHubPullRequestMock).not.toHaveBeenCalled()
})

it('revalidates committed branch work before creating a GitHub pull request', async () => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'rev-parse') {
return { stdout: 'feature\n', stderr: '' }
}
if (args[0] === 'status') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'log' && args.includes('--pretty=%s')) {
return { stdout: 'Feature title\n', stderr: '' }
}
if (args[0] === 'log') {
return { stdout: '\n', stderr: '' }
}
if (args[0] === 'rev-list' && args.includes('--count')) {
return { stdout: '0\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})

await expect(
createHostedReview('/repo', {
provider: 'github',
base: 'main',
head: 'feature',
title: 'Feature'
})
).resolves.toEqual({
ok: false,
code: 'validation',
error: 'Create PR failed: commit changes before creating a pull request.'
})
expect(createGitHubPullRequestMock).not.toHaveBeenCalled()
})

it('rejects creation when the selected head is no longer checked out', async () => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'rev-parse') {
Expand Down Expand Up @@ -204,9 +242,12 @@ describe('createHostedReview', () => {
if (args[0] === 'rev-parse' && args[2] === 'HEAD@{u}') {
return { stdout: 'origin/feature\n', stderr: '' }
}
if (args[0] === 'rev-list') {
if (args[0] === 'rev-list' && args.includes('--left-right')) {
return { stdout: '0 0\n', stderr: '' }
}
if (args[0] === 'rev-list' && args.includes('--count')) {
return { stdout: '1\n', stderr: '' }
}
if (args[0] === 'log' && args.includes('--pretty=%s')) {
return { stdout: 'Feature title\n', stderr: '' }
}
Expand Down Expand Up @@ -296,7 +337,18 @@ describe('getHostedReviewCreationEligibility', () => {
mockGitHubProvider()
getHostedReviewForBranchMock.mockResolvedValue(null)
ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'Feature title\n', stderr: '' })
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'log' && args.includes('--pretty=%s')) {
return { stdout: 'Feature title\n', stderr: '' }
}
if (args[0] === 'log') {
return { stdout: '- Feature title\n', stderr: '' }
}
if (args[0] === 'rev-list') {
return { stdout: '1\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
})

it('treats short remote base refs as the default branch name', async () => {
Expand Down Expand Up @@ -356,8 +408,46 @@ describe('getHostedReviewCreationEligibility', () => {
defaultBaseRef: 'origin/main',
head: 'feature/create-pr',
title: 'Feature title',
body: 'Feature title'
body: '- Feature title',
hasCommittedChanges: true
})
})

it('counts committed review work against the remote-tracking base when only a short base is provided', async () => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'log' && args.includes('--pretty=%s')) {
return { stdout: 'Feature title\n', stderr: '' }
}
if (args[0] === 'log') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'rev-list' && args.includes('origin/main..HEAD')) {
return { stdout: '1\n', stderr: '' }
}
if (args[0] === 'rev-list') {
throw new Error(`Unexpected rev-list candidate: ${args.join(' ')}`)
}
return { stdout: '', stderr: '' }
})

await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/create-pr',
base: 'main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
canCreate: true,
hasCommittedChanges: true
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['rev-list', '--count', 'origin/main..HEAD'],
{ cwd: '/repo' }
)
})

it('resolves remote eligibility through SSH repo metadata', async () => {
Expand All @@ -369,6 +459,9 @@ describe('getHostedReviewCreationEligibility', () => {
if (args[0] === 'log') {
return { stdout: '- Remote title\n', stderr: '' }
}
if (args[0] === 'rev-list') {
return { stdout: '1\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
}
Expand All @@ -389,7 +482,8 @@ describe('getHostedReviewCreationEligibility', () => {
provider: 'github',
canCreate: true,
title: 'Remote title',
body: '- Remote title'
body: '- Remote title',
hasCommittedChanges: true
})

expect(getProjectSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1')
Expand Down Expand Up @@ -418,6 +512,39 @@ describe('getHostedReviewCreationEligibility', () => {
})
})

it('reports no committed review changes when the branch matches the base', async () => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'log' && args.includes('--pretty=%s')) {
return { stdout: 'Feature title\n', stderr: '' }
}
if (args[0] === 'log') {
return { stdout: '\n', stderr: '' }
}
if (args[0] === 'rev-list') {
return { stdout: '0\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})

await expect(
getHostedReviewCreationEligibility({
repoPath: '/repo',
branch: 'feature/no-delta',
base: 'origin/main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
canCreate: false,
blockedReason: 'no_committed_changes',
nextAction: 'commit',
body: null,
hasCommittedChanges: false
})
})

it('blocks unsupported providers before GitHub authentication checks', async () => {
getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'acme/orca' })
getRepoSlugMock.mockResolvedValue(null)
Expand Down
65 changes: 61 additions & 4 deletions src/main/source-control/hosted-review-creation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,44 @@ async function getCommitSummaryBody(
}
}

async function hasCommittedChangesForReview(
repoPath: string,
base: string | null,
connectionId?: string | null
): Promise<boolean> {
if (!base) {
return false
}

const normalizedBase = normalizeHostedReviewBaseRef(base)
const candidates = new Set<string>()
if (!base.startsWith('refs/') && !base.includes('/')) {
// Why: submit preflight receives provider base names like "main", but a
// local checkout may only have the remote-tracking ref available.
candidates.add(`origin/${base}`)
candidates.add(`upstream/${base}`)
}
candidates.add(base)
candidates.add(normalizedBase)

for (const candidate of candidates) {
try {
const { stdout } = await runGitForHostedReview(
repoPath,
['rev-list', '--count', `${candidate}..HEAD`],
connectionId
)
const count = Number.parseInt(stdout.trim(), 10)
if (Number.isFinite(count)) {
return count > 0
}
} catch {
// Try the next plausible local spelling of the provider base branch.
}
}
return false
}

async function getDefaultBaseRef(
repoPath: string,
connectionId?: string | null
Expand Down Expand Up @@ -243,6 +281,11 @@ const blockedCreateResultByReason = {
code: 'validation',
error: 'Create PR failed: choose a feature branch before creating a pull request.'
},
no_committed_changes: {
ok: false,
code: 'validation',
error: 'Create PR failed: commit changes before creating a pull request.'
},
no_upstream: {
ok: false,
code: 'validation',
Expand Down Expand Up @@ -355,16 +398,20 @@ export async function getHostedReviewCreationEligibility(
connectionId: args.connectionId ?? null
})

const title =
(await getLatestCommitSubject(args.repoPath, args.connectionId)) ?? branchToTitle(branch)
const body = await getCommitSummaryBody(args.repoPath, defaultBaseRef ?? null, args.connectionId)
const [latestCommitSubject, body, hasCommittedChanges] = await Promise.all([
getLatestCommitSubject(args.repoPath, args.connectionId),
getCommitSummaryBody(args.repoPath, defaultBaseRef ?? null, args.connectionId),
hasCommittedChangesForReview(args.repoPath, defaultBaseRef ?? null, args.connectionId)
])
const title = latestCommitSubject ?? branchToTitle(branch)
const baseResult = {
provider,
review: review ? { number: review.number, url: review.url } : null,
defaultBaseRef,
head: branch || null,
title,
body
body,
hasCommittedChanges
}

if (!branch || branch === 'HEAD') {
Expand Down Expand Up @@ -392,6 +439,16 @@ export async function getHostedReviewCreationEligibility(
if (args.hasUncommittedChanges) {
return { ...baseResult, canCreate: false, blockedReason: 'dirty', nextAction: 'commit' }
}
// Why: every PR creation entry point consumes canCreate; keeping the
// no-delta rule here prevents renderer and main-process preflight drift.
if (baseBranch && !hasCommittedChanges) {
return {
...baseResult,
canCreate: false,
blockedReason: 'no_committed_changes',
nextAction: 'commit'
}
}
if (args.hasUpstream === false) {
return { ...baseResult, canCreate: false, blockedReason: 'no_upstream', nextAction: 'publish' }
}
Expand Down
10 changes: 6 additions & 4 deletions src/renderer/src/components/right-sidebar/ChecksPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1150,7 +1150,7 @@ export default function ChecksPanel(): React.JSX.Element {
const isPausedPRRefresh = prRefreshState?.status === 'paused'
const isErroredPRRefresh = prRefreshState?.status === 'error'

const canCreate = hostedReviewCreation?.canCreate
const canCreate = hostedReviewCreation?.canCreate === true
const canPushCreate = hostedReviewCreation?.blockedReason === 'needs_push'
return (
<>
Expand Down Expand Up @@ -1191,9 +1191,11 @@ export default function ChecksPanel(): React.JSX.Element {
? 'Refreshing GitHub status for this branch'
: isPausedPRRefresh
? 'GitHub refresh is paused by the current rate-limit budget'
: canPushCreate
? 'Push your branch before creating a pull request.'
: 'Create a pull request to start checks and review.'}
: hostedReviewCreation?.blockedReason === 'no_committed_changes'
? 'Commit changes before creating a pull request.'
: canPushCreate
? 'Push your branch before creating a pull request.'
: 'Create a pull request to start checks and review.'}
</div>
{!operationInProgress && (
<div className="mt-3 flex flex-wrap gap-2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -373,4 +373,26 @@ describe('resolveDropdownItems', () => {
expect(byKind.push_create_pr.label).toBe('Push before PR')
expect(byKind.push_create_pr.disabled).toBe(false)
})

it('keeps PR creation disabled when there is no committed branch work', () => {
const items = resolveDropdownItems(
inputs({
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'no_committed_changes',
nextAction: 'commit',
hasCommittedChanges: false
}
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.create_pr.disabled).toBe(true)
expect(byKind.create_pr.hint).toBe('Commit changes first')
expect(byKind.push_create_pr.disabled).toBe(true)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,8 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr
return 'Check out a branch first'
case 'default_branch':
return 'Switch to a feature branch'
case 'no_committed_changes':
return 'Commit changes first'
case 'no_upstream':
return 'Publish Branch'
case 'needs_push':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -408,4 +408,26 @@ describe('resolvePrimaryAction', () => {
disabled: false
})
})

it('keeps the primary disabled when review creation has no committed branch work', () => {
const result = resolvePrimaryAction(
inputs({
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'no_committed_changes',
nextAction: 'commit',
hasCommittedChanges: false
}
})
)
expect(result).toEqual({
kind: 'commit',
label: 'Commit',
title: 'Nothing to commit. Branch is up to date.',
disabled: true
})
})
})
2 changes: 2 additions & 0 deletions src/shared/hosted-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export type HostedReviewCreationBlockedReason =
| 'dirty'
| 'detached_head'
| 'default_branch'
| 'no_committed_changes'
| 'no_upstream'
| 'needs_push'
| 'needs_sync'
Expand Down Expand Up @@ -106,6 +107,7 @@ export type HostedReviewCreationEligibility = {
head?: string | null
title?: string | null
body?: string | null
hasCommittedChanges?: boolean
}

export type HostedReviewCreationEligibilityArgs = {
Expand Down
Loading