Add Source Control Create PR flow (#5436)

* Add Source Control Create PR intent flow

Implements the Source Control Create PR flow described in docs/source-control-create-pr-flow.md.

* Keep Commit visible beside Create PR

* Fix Create PR partial staging action band

* Integrate hosted review creation into Create PR intent flow

- Automatically create the pull or merge request on GitHub/GitLab after
  successfully staging, committing, and pushing in the intent flow.
- Introduce a unified `updateCommitDrafts` helper to keep React state and
  its ref synchronized, preventing draft-overwrite race conditions.
- Split primary action tests into focused files to satisfy the ESLint
  `max-lines` rule.
- Replace hardcoded "Local Mac" strings with dynamic host labels.

* Support Azure DevOps and Gitea PR creation and limit large diffs

Implement automated pull request creation for Azure DevOps and Gitea
repositories. This includes REST API integration, credential checks via
environment variables, template support, and error classification.

Additionally, introduce limits on large diff payloads in git status
extraction to prevent renderer-freezing performance bottlenecks when
loading extremely large files.

* Skip source control refetches when PR creation intent is in flight

Avoid recomputing branch eligibility while isCreatePrIntentInFlight is true.
This prevents tearing down the PR composer or rotating dropdown hints
prematurely if ahead/behind or dirty states are temporarily perturbed
temporarily perturbed mid-flow.

* Expose manual prerequisite actions next to Create PR button

Previously, the Create PR intent only supported "Stage All" as a
sibling action. This expands prerequisite resolution to handle other
intermediate steps such as committing, publishing, and pushing
(including force pushing).

This ensures the edit-commit-push-review loop remains streamlined
directly within the CommitArea by displaying the specific required
next action beside the primary Create PR button.

* Move PR creation actions from CommitArea to sidebar header

- Decouples PR creation and PR intent actions from the local commit area
  primary button, ensuring local/remote git actions remain primary.
- Renders a dedicated PR creation button in the source control header
  beside the hosted review status.
- Simplifies CommitArea by removing prerequisite split-button rendering
  and review composer logic.

* Delete source control create PR flow design document

Remove the design document for the source control create PR flow as the feature has been successfully implemented.

* Display PR creation errors in inline notice

Unify PR/review creation error reporting by replacing the duplicate
createPrErrors state with the shared createPrIntentNotice. Validation
and API errors are now shown directly within the visible inline alert
notice to improve layout consistency and visibility.

Also refactor the execution host platform label lookup to use simple
if statements instead of a switch block.

* Improve Create PR intent flow safety and provider awareness

- Integrate the hosted review composer directly into the Source Control
  panel when a direct review creation action is available.
- Abort the in-flight PR creation intent flow early if the current git
  branch changes to prevent staging or committing on the wrong target.
- Keep in-flight action labels provider-aware (e.g., "Create MR" on GitLab)
  by passing hosted review inputs to the action resolver.
- Omit large diff text payloads from git status responses when line counts
  exceed safe rendering limits to avoid UI performance degradation.
- Ensure field generation does not retarget the base branch of a PR/MR without
  explicit user confirmation.

* Preserve PR and MR templates in AI pull request generation

- Preload templates (including GitLab merge requests) into the AI
  context before generation to prevent bypassing provider-side fallbacks.
- Instruct the AI generator to fill out and preserve existing template
  headings, required sections, and checklists instead of deleting them.
- Pass provider and template settings from the renderer to the backend
  RPC and runtime handlers.

* Mock DropdownMenuShortcut in tab-title-tooltip test

Add a mock for the DropdownMenuShortcut component in the dropdown menu
mock to prevent test failures.
This commit is contained in:
Jinjing
2026-06-16 15:36:27 -07:00
committed by GitHub
parent ca05dbf652
commit a300e2c7b4
64 changed files with 4161 additions and 528 deletions
@@ -1,15 +1,16 @@
import { describe, expect, it } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import { getPaletteHostBadge } from './palette-host-badge'
import { buildSidebarHostOptions } from '../sidebar/sidebar-host-options'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
// Why: a connected SSH state makes the target a live remote, which is what the
// palette badge now requires before disambiguating rows with a host label.
const connectedSshStates = (targetId: string) =>
new Map([
[targetId, { targetId, status: 'connected' as const, error: null, reconnectAttempt: 0 }]
])
const localHostLabel = getLocalExecutionHostLabel()
describe('getPaletteHostBadge', () => {
it('returns null for single-host (local-only) workspaces', () => {
@@ -44,7 +45,7 @@ describe('getPaletteHostBadge', () => {
expect(getPaletteHostBadge({ connectionId: null }, hosts)).toEqual({
hostId: 'local',
label: localHostLabel
label: LOCAL_HOST_LABEL
})
})
@@ -114,7 +115,7 @@ describe('getPaletteHostBadge', () => {
expect(getPaletteHostBadge({}, hosts)).toEqual({
hostId: 'local',
label: localHostLabel
label: LOCAL_HOST_LABEL
})
})
@@ -13,7 +13,7 @@ export type PaletteHostBadge = {
// Why: Cmd+J only needs a host label when there's a live remote to disambiguate
// from. A merely-configured-but-disconnected SSH/runtime host shouldn't tag every
// row with "Local Mac", so we require an actually-reachable non-local host —
// row with the local host label, so we require an actually-reachable non-local host —
// unlike the sidebar gate, which lists disconnected hosts so users can connect.
function hasActiveRemoteHost(hostOptions: readonly SidebarHostOption[]): boolean {
return hostOptions.some(
@@ -70,6 +70,7 @@ import type {
HostedReviewCreationEligibility,
HostedReviewProvider
} from '../../../../shared/hosted-review'
import { resolveHostedReviewCreationProvider } from '../../../../shared/hosted-review-creation-providers'
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
import { getHostedReviewCacheKey, refreshHostedReviewCard } from '@/store/slices/hosted-review'
import { toast } from 'sonner'
@@ -694,8 +695,9 @@ export default function ChecksPanel(): React.JSX.Element {
hostedReviewCreationSnapshot?.requestKey === hostedReviewCreationRequestKey
? hostedReviewCreationSnapshot.data
: null
const hostedReviewCreateProvider: HostedReviewProvider =
hostedReviewCreation?.provider === 'gitlab' ? 'gitlab' : 'github'
const hostedReviewCreateProvider = resolveHostedReviewCreationProvider(
hostedReviewCreation?.provider
)
const hostedReviewCreateCopy = localizedHostedReviewCopy(hostedReviewCreateProvider)
const handleBranchChangedByPullRequestGeneration = useCallback(async (): Promise<void> => {
if (!activeWorktreeId || !activeWorktree?.path) {
@@ -2669,17 +2671,27 @@ export default function ChecksPanel(): React.JSX.Element {
if (activeWorktreeId && result.provider === 'gitlab') {
await updateWorktreeMeta(activeWorktreeId, { linkedGitLabMR: result.number })
}
if (activeWorktreeId && result.provider === 'azure-devops') {
await updateWorktreeMeta(activeWorktreeId, { linkedAzureDevOpsPR: result.number })
}
if (activeWorktreeId && result.provider === 'gitea') {
await updateWorktreeMeta(activeWorktreeId, { linkedGiteaPR: result.number })
}
const linkedReviewNumbers = {
linkedGitHubPR: result.provider === 'github' ? result.number : linkedPR,
fallbackGitHubPR: fallbackGitHubPRNumber,
linkedGitLabMR: result.provider === 'gitlab' ? result.number : linkedGitLabMR,
linkedBitbucketPR,
linkedAzureDevOpsPR:
result.provider === 'azure-devops' ? result.number : linkedAzureDevOpsPR,
linkedGiteaPR: result.provider === 'gitea' ? result.number : linkedGiteaPR
}
if (result.provider === 'gitlab') {
const refreshedReview = await refreshHostedReviewCard(fetchHostedReviewForBranch, {
repoPath: repo.path,
repoId: repo.id,
branch,
linkedGitHubPR: linkedPR,
fallbackGitHubPR: fallbackGitHubPRNumber,
linkedGitLabMR: result.number,
linkedBitbucketPR,
linkedAzureDevOpsPR,
linkedGiteaPR
...linkedReviewNumbers
})
const refreshedGitLabReview =
refreshedReview?.provider === 'gitlab' ? refreshedReview : null
@@ -2690,6 +2702,15 @@ export default function ChecksPanel(): React.JSX.Element {
})
return
}
if (result.provider !== 'github') {
await refreshHostedReviewCard(fetchHostedReviewForBranch, {
repoPath: repo.path,
repoId: repo.id,
branch,
...linkedReviewNumbers
})
return
}
await refreshLinkedGitHubPullRequest(result.number)
} catch {
// The success toast keeps the hosted URL available; Checks can be refreshed manually.
@@ -2703,6 +2724,7 @@ export default function ChecksPanel(): React.JSX.Element {
linkedAzureDevOpsPR,
linkedBitbucketPR,
linkedGiteaPR,
linkedGitLabMR,
linkedPR,
refreshLinkedGitHubPullRequest,
repo,
@@ -1,7 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { CommitArea, ConflictSummaryCard, OperationBanner } from './SourceControl'
import { resolvePrimaryAction, type PrimaryActionInputs } from './source-control-primary-action'
import {
resolveCommitAreaPrimaryAction,
type PrimaryActionInputs
} from './source-control-primary-action'
import { resolveDropdownItems, type DropdownActionKind } from './source-control-dropdown-items'
import { TooltipProvider } from '@/components/ui/tooltip'
@@ -39,7 +42,7 @@ function baseProps(overrides: Partial<PrimaryActionInputs> = {}) {
hasUnresolvedConflicts: inputs.hasUnresolvedConflicts,
isRemoteOperationActive: inputs.isRemoteOperationActive,
inFlightRemoteOpKind: inputs.inFlightRemoteOpKind ?? null,
primaryAction: resolvePrimaryAction(inputs),
primaryAction: resolveCommitAreaPrimaryAction(inputs),
dropdownItems: resolveDropdownItems(inputs),
onCommitMessageChange: vi.fn(),
onGenerate: vi.fn(),
@@ -50,7 +53,7 @@ function baseProps(overrides: Partial<PrimaryActionInputs> = {}) {
}
}
function renderCommitArea(props: ReturnType<typeof baseProps>): string {
function renderCommitArea(props: Parameters<typeof CommitArea>[0]): string {
return renderToStaticMarkup(
<TooltipProvider>
<CommitArea {...props} />
@@ -327,6 +330,96 @@ describe('CommitArea', () => {
expect(button).toContain('animate-spin')
expect(button).not.toContain('lucide-check')
})
it('keeps Stage All as the commit-area primary when review prep can stage changes', () => {
const input = buildInputs({
stagedCount: 0,
hasUnstagedChanges: true,
hasStageableChanges: true,
hasPartiallyStagedChanges: false,
hasMessage: false,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
const markup = renderCommitArea(baseProps(input))
const stageAllButton = firstButton(markup)
expect(stageAllButton).toContain('Stage All')
expect(stageAllButton).not.toContain('disabled=""')
expect(stageAllButton).toContain('lucide-plus')
expect(stageAllButton).toContain('rounded-r-none')
expect(markup).toContain('aria-label="More commit and remote actions"')
expect(markup).toContain('Stage all changes')
expect(
(markup.match(/<button\b[\s\S]*?<\/button>/g) ?? []).some((button) =>
button.includes('Commit</button>')
)
).toBe(false)
})
it('keeps Push as the commit-area primary when review prep can create after pushing', () => {
const input = buildInputs({
stagedCount: 0,
hasUnstagedChanges: false,
hasStageableChanges: false,
hasPartiallyStagedChanges: false,
hasMessage: false,
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push'
}
})
const markup = renderCommitArea(baseProps(input))
const pushButton = firstButton(markup)
expect(pushButton).toContain('Push')
expect(pushButton).not.toContain('disabled=""')
expect(pushButton).toContain('lucide-arrow-up')
expect(pushButton).toContain('rounded-r-none')
expect(markup).toContain('aria-label="More commit and remote actions"')
})
it('hides the composer generate affordance while Create PR intent is in flight', () => {
const markup = renderCommitArea({
...baseProps(),
aiEnabled: true,
aiAgentConfigured: true,
isGenerating: true,
isCreatePrIntentInFlight: true,
createPrIntentNotice: {
tone: 'muted',
message: 'Generating commit message…'
}
})
expect(markup).not.toContain('lucide-sparkles')
expect(markup).not.toContain('animate-spin')
expect(markup).toContain('Generating commit message…')
})
it('renders Create PR failures in the visible inline notice', () => {
const markup = renderCommitArea({
...baseProps(),
createPrIntentNotice: {
tone: 'destructive',
message: 'Create PR failed: push this branch first.'
}
})
expect(markup).toContain('id="commit-area-create-pr-intent"')
expect(markup).toContain('role="alert"')
expect(markup).toContain('Create PR failed: push this branch first.')
})
})
describe('ConflictSummaryCard', () => {
@@ -15,6 +15,7 @@ import type {
HostedReviewCreationEligibility,
HostedReviewProvider
} from '../../../../shared/hosted-review'
import { resolveHostedReviewCreationProvider } from '../../../../shared/hosted-review-creation-providers'
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields'
import {
@@ -68,7 +69,7 @@ export function CreatePullRequestDialog({
const submitInFlightRef = useRef(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const provider = eligibility?.provider === 'gitlab' ? 'gitlab' : 'github'
const provider = resolveHostedReviewCreationProvider(eligibility?.provider)
const copy = reviewCopy(provider)
const prCreationDefaults = React.useMemo(() => {
if (!settings) {
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { getRuntimeGitStatus } from '@/runtime/runtime-git-client'
import { getRuntimeGitStatus, getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-client'
import type {
GitPushTarget,
GitStatusResult,
@@ -83,3 +83,74 @@ export async function refreshGitStatusForWorktree({
runtimeTargetSettings: settings
})
}
export async function refreshGitStatusForWorktreeStrict({
settings,
worktreeId,
worktreePath,
connectionId,
pushTarget,
deps
}: {
settings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null
worktreeId: string
worktreePath: string
connectionId?: string
pushTarget?: GitPushTarget
deps: Omit<GitStatusRefreshDeps, 'fetchUpstreamStatus'> & {
fetchUpstreamStatus?: GitStatusRefreshDeps['fetchUpstreamStatus']
}
}): Promise<{ status: GitStatusResult; upstreamStatus: GitUpstreamStatus }> {
const status = (await getRuntimeGitStatus({
settings,
worktreeId,
worktreePath,
connectionId
})) as GitStatusResult
deps.setGitStatus(worktreeId, status)
// Why: branch switches can happen inside a terminal. `git status --branch`
// gives us the new identity without a separate worktree-list poll.
deps.updateWorktreeGitIdentity(worktreeId, {
head: status.head,
// Why: detached HEAD reports a head oid and no branch. Pass null as an
// explicit clear signal so stale branch names don't linger in the UI.
branch: status.branch ?? (status.head ? null : undefined)
})
if (pushTarget) {
// Why: porcelain status reports Git's configured upstream. Source Control
// actions for PR-created worktrees must instead reconcile with Orca's
// explicit publish target.
const upstreamStatus = await getRuntimeGitUpstreamStatus(
{ settings, worktreeId, worktreePath, connectionId },
pushTarget
)
deps.setUpstreamStatus(worktreeId, upstreamStatus)
return { status, upstreamStatus }
}
if (status.upstreamStatus) {
if (
status.upstreamStatus.ahead > 0 &&
status.upstreamStatus.behind > 0 &&
status.upstreamStatus.behindCommitsArePatchEquivalent === undefined
) {
// Why: porcelain status has counts but cannot tell stale post-rebase
// upstream commits from real remote work. Writing it first makes the
// primary action flicker between Sync and Force Push on every poll.
const upstreamStatus = await getRuntimeGitUpstreamStatus(
{ settings, worktreeId, worktreePath, connectionId },
undefined
)
deps.setUpstreamStatus(worktreeId, upstreamStatus)
return { status, upstreamStatus }
}
deps.setUpstreamStatus(worktreeId, status.upstreamStatus)
return { status, upstreamStatus: status.upstreamStatus }
}
const upstreamStatus = await getRuntimeGitUpstreamStatus(
{ settings, worktreeId, worktreePath, connectionId },
undefined
)
deps.setUpstreamStatus(worktreeId, upstreamStatus)
return { status, upstreamStatus }
}
@@ -0,0 +1,145 @@
import { describe, expect, it, vi } from 'vitest'
import {
createCreatePrIntentRunToken,
createPrIntentGitStatusMatchesToken,
createPrIntentRunTokenMatches,
getCreatePrIntentStagePaths,
resolveCreatePrIntentRemoteStep
} from './source-control-create-pr-intent-flow'
import type { GitStatusEntry } from '../../../../shared/types'
describe('source-control Create PR intent flow helpers', () => {
it('matches async completions only to the original repo, worktree, path, and branch', () => {
const now = vi.spyOn(Date, 'now').mockReturnValue(123)
try {
const token = createCreatePrIntentRunToken({
repoId: 'repo-1',
worktreeId: 'wt-1',
worktreePath: '/repo',
branch: 'feature'
})
expect(token.startedAt).toBe(123)
expect(createPrIntentRunTokenMatches(token, token)).toBe(true)
expect(createPrIntentRunTokenMatches(token, { ...token, branch: 'other' })).toBe(false)
expect(createPrIntentRunTokenMatches(token, { ...token, worktreeId: 'wt-2' })).toBe(false)
} finally {
now.mockRestore()
}
})
it('matches strict git status snapshots to the original branch', () => {
const token = createCreatePrIntentRunToken({
repoId: 'repo-1',
worktreeId: 'wt-1',
worktreePath: '/repo',
branch: 'feature/pr'
})
expect(createPrIntentGitStatusMatchesToken(token, { branch: 'refs/heads/feature/pr' })).toBe(
true
)
expect(createPrIntentGitStatusMatchesToken(token, { branch: 'feature/pr' })).toBe(true)
expect(createPrIntentGitStatusMatchesToken(token, { branch: 'refs/heads/other' })).toBe(false)
expect(createPrIntentGitStatusMatchesToken(token, { branch: null })).toBe(false)
})
it('stages only safe unstaged and untracked paths', () => {
const unresolved = {
path: 'conflicted.ts',
status: 'modified',
area: 'unstaged',
conflictKind: 'both_modified',
conflictStatus: 'unresolved'
} satisfies GitStatusEntry
expect(
getCreatePrIntentStagePaths({
unstaged: [{ path: 'safe.ts', status: 'modified', area: 'unstaged' }, unresolved],
untracked: [{ path: 'new.ts', status: 'untracked', area: 'untracked' }]
})
).toEqual(['safe.ts', 'new.ts'])
})
it('resolves safe remote steps for publish, push, and patch-equivalent force-push', () => {
expect(
resolveCreatePrIntentRemoteStep({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 2,
hasCurrentBranch: true,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'no_upstream',
nextAction: 'publish'
}
})
).toBe('publish')
expect(
resolveCreatePrIntentRemoteStep({
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
hasCurrentBranch: true,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push'
}
})
).toBe('push')
expect(
resolveCreatePrIntentRemoteStep({
upstreamStatus: {
hasUpstream: true,
ahead: 3,
behind: 2,
behindCommitsArePatchEquivalent: true
},
branchCommitsAhead: 3,
hasCurrentBranch: true,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_sync',
nextAction: 'sync'
}
})
).toBe('force_push')
})
it('blocks ordinary diverged branches and unpublished branches without commits', () => {
expect(
resolveCreatePrIntentRemoteStep({
upstreamStatus: { hasUpstream: true, ahead: 1, behind: 1 },
hasCurrentBranch: true,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_sync',
nextAction: 'sync'
}
})
).toBe('blocked')
expect(
resolveCreatePrIntentRemoteStep({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 0,
hasCurrentBranch: true,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'no_upstream',
nextAction: 'publish'
}
})
).toBe('blocked')
})
})
@@ -0,0 +1,93 @@
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs'
import type { GitStatusEntry, GitUpstreamStatus } from '../../../../shared/types'
import { getStageAllPaths } from './discard-all-sequence'
export type CreatePrIntentRemoteStep = 'publish' | 'push' | 'force_push' | 'blocked' | 'none'
export type CreatePrIntentRunToken = {
repoId: string
worktreeId: string
worktreePath: string
branch: string
startedAt: number
}
export type CreatePrIntentCurrentTarget = {
repoId?: string | null
worktreeId?: string | null
worktreePath?: string | null
branch?: string | null
}
export function createCreatePrIntentRunToken(input: Omit<CreatePrIntentRunToken, 'startedAt'>) {
return { ...input, startedAt: Date.now() }
}
export function createPrIntentRunTokenMatches(
token: CreatePrIntentRunToken,
current: CreatePrIntentCurrentTarget
): boolean {
return (
token.repoId === current.repoId &&
token.worktreeId === current.worktreeId &&
token.worktreePath === current.worktreePath &&
token.branch === current.branch
)
}
export function createPrIntentGitStatusMatchesToken(
token: CreatePrIntentRunToken,
status: { branch?: string | null }
): boolean {
const branch = normalizeHostedReviewHeadRef(status.branch ?? '')
return branch.length > 0 && branch === token.branch
}
export function getCreatePrIntentStagePaths(grouped: {
unstaged: GitStatusEntry[]
untracked: GitStatusEntry[]
}): string[] {
return [
...getStageAllPaths(grouped.unstaged, 'unstaged'),
...getStageAllPaths(grouped.untracked, 'untracked')
]
}
export function resolveCreatePrIntentRemoteStep({
upstreamStatus,
hostedReviewCreation,
branchCommitsAhead,
hasCurrentBranch
}: {
upstreamStatus: GitUpstreamStatus | undefined
hostedReviewCreation?: HostedReviewCreationEligibility | null
branchCommitsAhead?: number
hasCurrentBranch: boolean
}): CreatePrIntentRemoteStep {
if (!hasCurrentBranch || !hostedReviewCreation || hostedReviewCreation.canCreate) {
return 'none'
}
if (hostedReviewCreation.blockedReason === 'no_upstream') {
return branchCommitsAhead && branchCommitsAhead > 0 ? 'publish' : 'blocked'
}
if (hostedReviewCreation.blockedReason === 'needs_push') {
return 'push'
}
if (
hostedReviewCreation.blockedReason === 'needs_sync' &&
shouldForcePushWithLeaseForUpstream(upstreamStatus)
) {
return 'force_push'
}
if (hostedReviewCreation.blockedReason === 'needs_sync') {
return 'blocked'
}
return 'none'
}
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { resolveVisibleCreatePrHeaderAction } from './source-control-create-pr-intent-state'
import type { PrimaryAction } from './source-control-primary-action-types'
const createPrIntentAction: PrimaryAction = {
kind: 'create_pr_intent',
label: 'Create PR',
title: 'Preparing branch for review…',
disabled: true
}
const createPrAction: PrimaryAction = {
kind: 'create_pr',
label: 'Create PR',
title: 'Create a pull request for this branch',
disabled: false
}
describe('resolveVisibleCreatePrHeaderAction', () => {
it('hides the header when the hosted-review composer owns direct Create PR', () => {
expect(
resolveVisibleCreatePrHeaderAction({
createPrHeaderAction: createPrAction,
directCreatePrAction: createPrAction,
isCreatePrIntentInFlight: false,
primaryActionKind: 'create_pr'
})
).toBeNull()
})
it('hides the header while Create PR intent is in flight on the commit-area primary', () => {
expect(
resolveVisibleCreatePrHeaderAction({
createPrHeaderAction: createPrIntentAction,
directCreatePrAction: null,
isCreatePrIntentInFlight: true,
primaryActionKind: 'create_pr_intent'
})
).toBeNull()
})
it('keeps the header visible when intent is in flight but the primary is a prerequisite action', () => {
expect(
resolveVisibleCreatePrHeaderAction({
createPrHeaderAction: createPrIntentAction,
directCreatePrAction: null,
isCreatePrIntentInFlight: true,
primaryActionKind: 'publish'
})
).toEqual(createPrIntentAction)
})
})
@@ -0,0 +1,98 @@
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review'
import { supportsHostedReviewCreation } from '../../../../shared/hosted-review-creation-providers'
import type { GitUpstreamStatus } from '../../../../shared/types'
import type { PrimaryAction } from './source-control-primary-action-types'
export type CreatePrIntentKind =
| 'dirty'
| 'message_required'
| 'no_upstream'
| 'needs_push'
| 'force_push'
export type CreatePrIntentEligibility = {
eligible: boolean
kind: CreatePrIntentKind | null
}
export function resolveCreatePrIntentEligibility({
stagedCount,
hasStageableChanges,
hasMessage,
hasUnresolvedConflicts,
upstreamStatus,
hostedReviewCreation,
branchCommitsAhead,
hasCurrentBranch = true
}: {
stagedCount: number
hasStageableChanges: boolean
hasMessage: boolean
hasUnresolvedConflicts: boolean
upstreamStatus: GitUpstreamStatus | undefined
hostedReviewCreation?: HostedReviewCreationEligibility | null
branchCommitsAhead?: number
hasCurrentBranch?: boolean
}): CreatePrIntentEligibility {
if (
hasUnresolvedConflicts ||
!hasCurrentBranch ||
!hostedReviewCreation ||
hostedReviewCreation.canCreate ||
!supportsHostedReviewCreation(hostedReviewCreation.provider)
) {
return { eligible: false, kind: null }
}
if (hostedReviewCreation.blockedReason === 'dirty') {
if (stagedCount > 0 && !hasMessage) {
return { eligible: true, kind: 'message_required' }
}
return { eligible: stagedCount > 0 || hasStageableChanges, kind: 'dirty' }
}
if (hostedReviewCreation.blockedReason === 'no_upstream') {
const hasPublishableCommits = branchCommitsAhead === undefined ? false : branchCommitsAhead > 0
return {
eligible: hasPublishableCommits || stagedCount > 0 || hasStageableChanges,
kind: 'no_upstream'
}
}
if (hostedReviewCreation.blockedReason === 'needs_push') {
return { eligible: true, kind: 'needs_push' }
}
if (
hostedReviewCreation.blockedReason === 'needs_sync' &&
shouldForcePushWithLeaseForUpstream(upstreamStatus)
) {
return { eligible: true, kind: 'force_push' }
}
return { eligible: false, kind: null }
}
export function resolveVisibleCreatePrHeaderAction({
createPrHeaderAction,
directCreatePrAction,
isCreatePrIntentInFlight,
primaryActionKind
}: {
createPrHeaderAction: PrimaryAction | null
directCreatePrAction: PrimaryAction | null
isCreatePrIntentInFlight: boolean
primaryActionKind: PrimaryAction['kind']
}): PrimaryAction | null {
if (directCreatePrAction) {
return null
}
// Why: CommitArea already mirrors in-flight Create PR intent on the primary;
// keeping a second spinning header button stacks redundant spinners once
// message generation also shows one.
if (isCreatePrIntentInFlight && primaryActionKind === 'create_pr_intent') {
return null
}
return createPrHeaderAction
}
@@ -596,6 +596,54 @@ describe('resolveDropdownItems', () => {
expect(byKind.push_create_pr.disabled).toBe(false)
})
it.each(['azure-devops', 'gitea'] as const)(
'enables push-before-PR recovery for %s review creation',
(provider) => {
const items = resolveDropdownItems(
inputs({
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
hostedReviewCreation: {
provider,
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push'
}
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.create_pr.label).toBe('Create PR')
expect(byKind.create_pr.hint).toBe('Push first')
expect(byKind.push_create_pr.label).toBe('Push before PR')
expect(byKind.push_create_pr.title).toBe('Push local commits before creating a pull request')
expect(byKind.push_create_pr.disabled).toBe(false)
}
)
it.each([
['azure-devops', 'Set ORCA_AZURE_DEVOPS_TOKEN in this environment'],
['gitea', 'Set ORCA_GITEA_TOKEN in this environment']
] as const)('uses token auth copy when %s PR creation needs authentication', (provider, hint) => {
const items = resolveDropdownItems(
inputs({
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
hostedReviewCreation: {
provider,
review: null,
canCreate: false,
blockedReason: 'auth_required',
nextAction: 'authenticate'
}
})
)
const byKind = Object.fromEntries(
items.filter((e) => e.kind !== 'separator').map((e) => [e.kind, e])
)
expect(byKind.create_pr.hint).toBe(hint)
})
it('uses GitLab auth copy when MR creation needs authentication', () => {
const items = resolveDropdownItems(
inputs({
@@ -4,6 +4,7 @@
import type { PrimaryActionInputs } from './source-control-primary-action'
import type { GitConflictOperation } from '../../../../shared/types'
import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status'
import { supportsHostedReviewCreation } from '../../../../shared/hosted-review-creation-providers'
import { translate } from '@/i18n/i18n'
import {
localizedHostedReviewCopy,
@@ -96,11 +97,19 @@ function formatRebaseBaseRef(baseRef: string): string {
function reviewCopy(
provider: NonNullable<PrimaryActionInputs['hostedReviewCreation']>['provider'] | undefined
): ReturnType<typeof localizedHostedReviewCopy> & {
authCommand: 'gh auth login' | 'glab auth login'
authInstruction: string
} {
const authInstruction =
provider === 'gitlab'
? 'Run glab auth login'
: provider === 'azure-devops'
? 'Set ORCA_AZURE_DEVOPS_TOKEN'
: provider === 'gitea'
? 'Set ORCA_GITEA_TOKEN'
: 'Run gh auth login'
return {
...localizedHostedReviewCopy(resolveSupportedHostedReviewCopyProvider(provider)),
authCommand: provider === 'gitlab' ? 'glab auth login' : 'gh auth login'
authInstruction
}
}
@@ -491,7 +500,7 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr
case 'needs_sync':
return shouldForcePushWithLease ? 'Force Push first' : 'Sync first'
case 'auth_required':
return `Run ${createReviewCopy.authCommand} in this environment`
return `${createReviewCopy.authInstruction} in this environment`
case 'unsupported_provider':
return 'Unsupported provider'
case 'existing_review':
@@ -521,7 +530,7 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr
const canPushAndCreate =
!globalBusy &&
!upstreamLoading &&
(hostedReviewCreation?.provider === 'github' || hostedReviewCreation?.provider === 'gitlab') &&
supportsHostedReviewCreation(hostedReviewCreation?.provider) &&
(hostedReviewCreation.blockedReason === 'needs_push' ||
(hostedReviewCreation.blockedReason === 'needs_sync' && shouldForcePushWithLease))
const pushCreatePRItem: DropdownItem = {
@@ -15,6 +15,7 @@ export type PrimaryActionKind =
| 'pull'
| 'sync'
| 'publish'
| 'create_pr_intent'
| 'create_pr'
// Why: the in-flight remote op tracker stores which action the user actually
@@ -65,6 +66,7 @@ export type PrimaryActionInputs = {
// Why: detached HEAD can look like an unpublished branch from upstream
// status alone, but it has no branch ref that Publish Branch can push.
hasCurrentBranch?: boolean
isPrIntentInFlight?: boolean
}
export const PRIMARY_LABEL_BY_KIND: Record<Exclude<PrimaryActionKind, 'commit'>, string> = {
@@ -73,5 +75,6 @@ export const PRIMARY_LABEL_BY_KIND: Record<Exclude<PrimaryActionKind, 'commit'>,
pull: 'Pull',
sync: 'Sync',
publish: 'Publish Branch',
create_pr_intent: 'Create PR',
create_pr: 'Create PR'
}
@@ -0,0 +1,281 @@
import { describe, expect, it } from 'vitest'
import {
resolveCommitAreaPrimaryAction,
resolvePrimaryAction,
type PrimaryActionInputs
} from './source-control-primary-action'
import { resolveCreatePrHeaderAction } from './source-control-primary-create-pr-intent-action'
function inputs(overrides: Partial<PrimaryActionInputs> = {}): PrimaryActionInputs {
return {
stagedCount: 0,
hasUnstagedChanges: false,
hasStageableChanges: false,
hasPartiallyStagedChanges: false,
hasMessage: false,
hasUnresolvedConflicts: false,
isCommitting: false,
isRemoteOperationActive: false,
upstreamStatus: undefined,
...overrides
}
}
const upstreamInSync = {
hasUpstream: true,
upstreamName: 'origin/main',
ahead: 0,
behind: 0
}
describe('resolvePrimaryAction Create PR intent', () => {
it('returns Create PR intent for an unpublished clean branch with commits to publish', () => {
const result = resolvePrimaryAction(
inputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 2,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'no_upstream',
nextAction: 'publish'
}
})
)
expect(result.kind).toBe('create_pr_intent')
expect(result.disabled).toBe(false)
})
it('returns Create PR intent for patch-equivalent force-push before review', () => {
const result = resolvePrimaryAction(
inputs({
branchCommitsAhead: 4,
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 14,
behind: 3,
behindCommitsArePatchEquivalent: true
},
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_sync',
nextAction: 'sync'
}
})
)
expect(result.kind).toBe('create_pr_intent')
expect(result.disabled).toBe(false)
})
it('returns Create PR intent for a branch that needs a safe push before review', () => {
const input = inputs({
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push'
}
})
const result = resolvePrimaryAction(input)
expect(result.kind).toBe('create_pr_intent')
expect(result.disabled).toBe(false)
expect(resolveCreatePrHeaderAction(input)).toEqual(result)
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'push',
label: 'Push',
title: 'Push 2 commits',
disabled: false
})
})
it('returns Create PR intent for a dirty tree when hosted review prep can commit changes', () => {
const result = resolvePrimaryAction(
inputs({
hasUnstagedChanges: true,
hasStageableChanges: true,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
)
expect(result).toEqual({
kind: 'create_pr_intent',
label: 'Create PR',
title: 'Prepare this branch and create a pull request',
disabled: false
})
})
it('returns Create PR intent for staged changes without a message so the flow can request one', () => {
const result = resolvePrimaryAction(
inputs({
stagedCount: 1,
hasMessage: false,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
)
expect(result.kind).toBe('create_pr_intent')
expect(result.disabled).toBe(false)
})
it('returns Create MR intent with provider copy for a GitLab dirty branch', () => {
const result = resolvePrimaryAction(
inputs({
stagedCount: 1,
hasMessage: true,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'gitlab',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
)
expect(result.kind).toBe('create_pr_intent')
expect(result.label).toBe('Create MR')
expect(result.title).toBe('Prepare this branch and create a merge request')
})
it('keeps in-flight Create MR intent copy provider-aware', () => {
const input = inputs({
isPrIntentInFlight: true,
hostedReviewCreation: {
provider: 'gitlab',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
expect(resolvePrimaryAction(input)).toEqual({
kind: 'create_pr_intent',
label: 'Create MR',
title: 'Preparing branch for review…',
disabled: true
})
expect(resolveCreatePrHeaderAction(input)).toEqual({
kind: 'create_pr_intent',
label: 'Create MR',
title: 'Preparing branch for review…',
disabled: true
})
})
it.each(['azure-devops', 'gitea'] as const)(
'returns Create PR intent for a %s branch that needs a safe push before review',
(provider) => {
const result = resolvePrimaryAction(
inputs({
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 },
hostedReviewCreation: {
provider,
review: null,
canCreate: false,
blockedReason: 'needs_push',
nextAction: 'push'
}
})
)
expect(result).toEqual({
kind: 'create_pr_intent',
label: 'Create PR',
title: 'Prepare this branch and create a pull request',
disabled: false
})
}
)
it('separates Publish Branch from the Create PR header action for unpublished commits', () => {
const input = inputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
branchCommitsAhead: 2,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'no_upstream',
nextAction: 'publish'
}
})
expect(resolveCreatePrHeaderAction(input)?.kind).toBe('create_pr_intent')
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'publish',
label: 'Publish Branch',
title: 'Publish this branch to origin',
disabled: false
})
})
it('separates Force Push from the Create PR header action for patch-equivalent divergence', () => {
const input = inputs({
branchCommitsAhead: 4,
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 14,
behind: 3,
behindCommitsArePatchEquivalent: true
},
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'needs_sync',
nextAction: 'sync'
}
})
expect(resolveCreatePrHeaderAction(input)?.kind).toBe('create_pr_intent')
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'push',
label: 'Force Push',
title:
'Remote only has older copies of local commits. Force push 4 branch commits with lease to update origin/feature.',
disabled: false
})
})
it('returns direct Create PR as a header action when the branch is ready', () => {
expect(
resolveCreatePrHeaderAction(
inputs({
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null
}
})
)
).toEqual({
kind: 'create_pr',
label: 'Create PR',
title: 'Create a pull request for this branch',
disabled: false
})
})
})
@@ -1,6 +1,9 @@
/* eslint-disable max-lines -- Why: this state-machine table intentionally keeps every primary-action priority case together so merge regressions are visible in one file. */
import { describe, expect, it } from 'vitest'
import { resolvePrimaryAction, type PrimaryActionInputs } from './source-control-primary-action'
import {
resolveCommitAreaPrimaryAction,
resolvePrimaryAction,
type PrimaryActionInputs
} from './source-control-primary-action'
// Why: a shared defaults object keeps each case row terse while making the
// "this is the one knob that differs from the baseline" intent obvious.
@@ -380,6 +383,58 @@ describe('resolvePrimaryAction', () => {
expect(result.disabled).toBe(false)
})
it('keeps Stage All available in the commit area when Create PR intent is additive', () => {
const input = inputs({
stagedCount: 0,
hasUnstagedChanges: true,
hasStageableChanges: true,
hasPartiallyStagedChanges: false,
hasMessage: false,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
expect(resolvePrimaryAction(input).kind).toBe('create_pr_intent')
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'stage',
label: 'Stage All',
title: 'Stage all changes',
disabled: false
})
})
it('keeps the partial-staging reason on the additive commit-area Stage All action', () => {
const input = inputs({
stagedCount: 1,
hasUnstagedChanges: true,
hasStageableChanges: true,
hasPartiallyStagedChanges: true,
hasMessage: true,
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider: 'github',
review: null,
canCreate: false,
blockedReason: 'dirty',
nextAction: 'commit'
}
})
expect(resolvePrimaryAction(input).kind).toBe('create_pr_intent')
expect(resolveCommitAreaPrimaryAction(input)).toEqual({
kind: 'stage',
label: 'Stage All',
title: 'Stage all changes before committing partially staged files',
disabled: false
})
})
it('still resolves to Commit when staged and unrelated unstaged files exist', () => {
const result = resolvePrimaryAction(
inputs({
@@ -486,4 +541,28 @@ describe('resolvePrimaryAction', () => {
disabled: false
})
})
it.each(['azure-devops', 'gitea'] as const)(
'returns Create PR when a clean tracked %s branch is eligible for review creation',
(provider) => {
const result = resolvePrimaryAction(
inputs({
upstreamStatus: upstreamInSync,
hostedReviewCreation: {
provider,
review: null,
canCreate: true,
blockedReason: null,
nextAction: null
}
})
)
expect(result).toEqual({
kind: 'create_pr',
label: 'Create PR',
title: 'Create a pull request for this branch',
disabled: false
})
}
)
})
@@ -14,6 +14,11 @@ import {
describePushCount,
describeSyncCounts
} from './source-control-primary-action-titles'
import {
resolveCreatePrIntentInFlightPrimaryAction,
resolveCreatePrIntentPrimaryAction
} from './source-control-primary-create-pr-intent-action'
import { resolveUnpublishedPrimaryAction } from './source-control-primary-unpublished-action'
export type {
PrimaryActionKind,
@@ -34,13 +39,15 @@ export type {
* 1. In-flight commit locks the primary to a disabled "Commit".
* 2. In-flight remote operation keeps the current label but disables it.
* 3. Unresolved conflicts block the commit path entirely.
* 4. Has partially staged files → "Stage All" to avoid hook-time partial
* 4. Create PR intent can own the primary; manual prerequisites are
* exposed as a visible sibling action by CommitArea.
* 5. Has partially staged files → "Stage All" to avoid hook-time partial
* stash conflicts.
* 5. Has staged files + message → plain "Commit" (compound flows live in
* the dropdown; after the commit lands, step 7 rotates the primary to
* the appropriate single remote action).
* 6. Has staged files + no message → disabled "Commit" with a reason.
* 7. Clean tree → adaptive remote action (or disabled "Commit" no-op).
* 6. Has staged files + message → plain "Commit" (compound flows live in
* the dropdown; after the commit lands, the clean-tree rung rotates
* the primary to the appropriate single remote action).
* 7. Has staged files + no message → disabled "Commit" with a reason.
* 8. Clean tree → adaptive remote action (or disabled "Commit" no-op).
*
* An undefined upstream status means fetchUpstreamStatus has not resolved
* yet for this worktree. We return a disabled Commit so the button has a
@@ -62,9 +69,14 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
isPRStateLoading,
hostedReviewCreation,
branchCommitsAhead,
hasCurrentBranch = true
hasCurrentBranch = true,
isPrIntentInFlight = false
} = inputs
if (isPrIntentInFlight) {
return resolveCreatePrIntentInFlightPrimaryAction(inputs)
}
// 1. Commit in flight — lock the primary no matter what else is true.
if (isCommitting) {
return {
@@ -101,11 +113,15 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
}
}
const createPrIntent = resolveCreatePrIntentPrimaryAction(inputs)
if (createPrIntent) {
return createPrIntent
}
const hasStaged = stagedCount > 0
// 4. A path with both staged and unstaged edits can make lint-staged's
// partial-stash restore fail after formatters rewrite the staged copy. Push
// the user through Stage All first so the index matches the worktree.
// Why: partial staging can break hook-time restores during the intent flow;
// keep Stage All visible as a sibling prerequisite without replacing Create PR.
if (hasStaged && hasPartiallyStagedChanges) {
return {
kind: 'stage',
@@ -194,78 +210,12 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
}
if (!upstreamStatus.hasUpstream) {
if (!hasCurrentBranch) {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.e61b0d7a3c',
'Check out a branch before publishing commits.'
),
disabled: true
}
}
if (branchCommitsAhead === 0) {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.acce237921',
'Nothing to commit. Branch has no changes to publish.'
),
disabled: true
}
}
if (isPRStateLoading) {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.41d4bcf157',
'Checking PR status…'
),
disabled: true
}
}
if (prState === 'merged') {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.3d5dccef0b',
'Nothing to commit. PR is already merged.'
),
disabled: true
}
}
return {
kind: 'publish',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.7b4d02e6b8',
'Publish Branch'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.1884cf34af',
'Publish this branch to origin'
),
disabled: false
}
return resolveUnpublishedPrimaryAction({
hasCurrentBranch,
branchCommitsAhead,
isPRStateLoading,
prState
})
}
if (upstreamStatus.ahead > 0 && upstreamStatus.behind > 0) {
@@ -347,3 +297,13 @@ export function resolvePrimaryAction(inputs: PrimaryActionInputs): PrimaryAction
disabled: true
}
}
export function resolveCommitAreaPrimaryAction(inputs: PrimaryActionInputs): PrimaryAction {
// Why: review creation is additive chrome. The commit area should keep the
// same local/remote primary action it would have without review eligibility.
return resolvePrimaryAction({
...inputs,
hostedReviewCreation: null,
isPrIntentInFlight: false
})
}
@@ -0,0 +1,96 @@
import { translate } from '@/i18n/i18n'
import {
localizedHostedReviewCopy,
resolveSupportedHostedReviewCopyProvider
} from '@/i18n/hosted-review-localized-copy'
import type { PrimaryAction, PrimaryActionInputs } from './source-control-primary-action-types'
import { resolveCreatePrIntentEligibility } from './source-control-create-pr-intent-state'
export function resolveCreatePrIntentInFlightPrimaryAction(
inputs?: Pick<PrimaryActionInputs, 'hostedReviewCreation'>
): PrimaryAction {
const copy = localizedHostedReviewCopy(
resolveSupportedHostedReviewCopyProvider(inputs?.hostedReviewCreation?.provider)
)
return {
kind: 'create_pr_intent',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.e7ffa46946',
'Create {{value0}}',
{ value0: copy.shortLabel }
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.d37e68f61d',
'Preparing branch for review…'
),
disabled: true
}
}
export function resolveCreatePrIntentPrimaryAction(
inputs: PrimaryActionInputs
): PrimaryAction | null {
const createPrIntent = resolveCreatePrIntentEligibility({
stagedCount: inputs.stagedCount,
hasStageableChanges: inputs.hasStageableChanges,
hasMessage: inputs.hasMessage,
hasUnresolvedConflicts: inputs.hasUnresolvedConflicts,
upstreamStatus: inputs.upstreamStatus,
hostedReviewCreation: inputs.hostedReviewCreation,
branchCommitsAhead: inputs.branchCommitsAhead,
hasCurrentBranch: inputs.hasCurrentBranch
})
if (!createPrIntent.eligible) {
return null
}
const copy = localizedHostedReviewCopy(
resolveSupportedHostedReviewCopyProvider(inputs.hostedReviewCreation?.provider)
)
return {
kind: 'create_pr_intent',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.e7ffa46946',
'Create {{value0}}',
{ value0: copy.shortLabel }
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.c72e5e65d1',
'Prepare this branch and create a {{value0}}',
{ value0: copy.reviewLabel }
),
disabled: false
}
}
export function resolveCreatePrHeaderAction(inputs: PrimaryActionInputs): PrimaryAction | null {
if (inputs.isPrIntentInFlight) {
return resolveCreatePrIntentInFlightPrimaryAction(inputs)
}
if (inputs.isCommitting || inputs.isRemoteOperationActive || inputs.hasUnresolvedConflicts) {
return null
}
if (inputs.hostedReviewCreation?.canCreate) {
const copy = localizedHostedReviewCopy(
resolveSupportedHostedReviewCopyProvider(inputs.hostedReviewCreation.provider)
)
return {
kind: 'create_pr',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.e7ffa46946',
'Create {{value0}}',
{ value0: copy.shortLabel }
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.946a8a05ea',
'Create a {{value0}} for this branch',
{ value0: copy.reviewLabel }
),
disabled: false
}
}
return resolveCreatePrIntentPrimaryAction(inputs)
}
@@ -0,0 +1,88 @@
import { translate } from '@/i18n/i18n'
import type { PrimaryAction } from './source-control-primary-action-types'
import type { PRState } from '../../../../shared/types'
export function resolveUnpublishedPrimaryAction({
hasCurrentBranch,
branchCommitsAhead,
isPRStateLoading,
prState
}: {
hasCurrentBranch: boolean
branchCommitsAhead?: number
isPRStateLoading?: boolean
prState?: PRState | null
}): PrimaryAction {
if (!hasCurrentBranch) {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.e61b0d7a3c',
'Check out a branch before publishing commits.'
),
disabled: true
}
}
if (branchCommitsAhead === 0) {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.acce237921',
'Nothing to commit. Branch has no changes to publish.'
),
disabled: true
}
}
if (isPRStateLoading) {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.41d4bcf157',
'Checking PR status…'
),
disabled: true
}
}
if (prState === 'merged') {
return {
kind: 'commit',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
'Commit'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.3d5dccef0b',
'Nothing to commit. PR is already merged.'
),
disabled: true
}
}
return {
kind: 'publish',
label: translate(
'auto.components.right.sidebar.source.control.primary.action.7b4d02e6b8',
'Publish Branch'
),
title: translate(
'auto.components.right.sidebar.source.control.primary.action.1884cf34af',
'Publish this branch to origin'
),
disabled: false
}
}
@@ -369,7 +369,9 @@ export function useCreatePullRequestDialogFields({
base: stripBaseRef(base.trim()),
title,
body,
draft
draft,
provider: eligibility?.provider,
useTemplate: resolvedPrDefaults.useTemplate
},
overrides
)
@@ -417,9 +419,11 @@ export function useCreatePullRequestDialogFields({
draft,
effectiveGenerating,
applyGeneratedFields,
eligibility?.provider,
generation,
generateDisabled,
onBranchChangedByGeneration,
resolvedPrDefaults.useTemplate,
settings,
title,
worktreeId,
@@ -3,7 +3,7 @@
import React, { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getLocalExecutionHostLabel, toSshExecutionHostId } from '../../../../shared/execution-host'
import { getExecutionHostLabel, toSshExecutionHostId } from '../../../../shared/execution-host'
import {
PROJECT_HOST_SETUP_RUNTIME_CAPABILITY,
RUNTIME_PROTOCOL_VERSION,
@@ -15,7 +15,8 @@ import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection'
let container: HTMLDivElement
let root: Root
const localHostLabel = getLocalExecutionHostLabel()
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
function makeRepo(overrides: Partial<Repo> & Pick<Repo, 'id' | 'displayName' | 'path'>): Repo {
return {
@@ -143,7 +144,7 @@ describe('RepositoryHostSetupsSection', () => {
renderSection(localRepo)
expect(container.textContent).toContain('Viewing host')
expect(container.textContent).toContain(localHostLabel)
expect(container.textContent).toContain(LOCAL_HOST_LABEL)
})
it('opens the selected host setup settings pane through the setup repo id', () => {
@@ -3,12 +3,14 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import {
GitHubIntegrationCard,
GitLabIntegrationCard
} from './cli-source-control-integration-cards'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
type StoreState = {
settings: { activeRuntimeEnvironmentId: string | null }
openSettingsPage: () => void
@@ -48,7 +50,6 @@ vi.mock('./source-control-preflight-card-status', () => ({
let root: Root | null = null
let container: HTMLDivElement | null = null
const localHostLabel = getLocalExecutionHostLabel()
async function renderCard(card: React.ReactNode): Promise<HTMLDivElement> {
container = document.createElement('div')
@@ -90,7 +91,7 @@ describe('CLI source-control integration card account scope', () => {
expect(rendered.textContent).toContain('GitHub')
expect(rendered.textContent).toContain('Connected')
expect(rendered.textContent).toContain(`Account scope: ${localHostLabel}`)
expect(rendered.textContent).toContain(`Account scope: ${LOCAL_HOST_LABEL}`)
expect(rendered.textContent).toContain(
'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.'
)
@@ -1,11 +1,13 @@
import { describe, expect, it } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import { getProviderAccountScope, getProviderRateLimitScope } from './provider-account-scope'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
describe('getProviderAccountScope', () => {
it('describes provider accounts as client-owned without an active runtime', () => {
expect(getProviderAccountScope({ activeRuntimeEnvironmentId: null })).toEqual({
label: getLocalExecutionHostLabel(),
label: LOCAL_HOST_LABEL,
description:
'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.'
})
@@ -21,7 +23,7 @@ describe('getProviderAccountScope', () => {
it('describes provider API budgets as host-scoped', () => {
expect(getProviderRateLimitScope({ activeRuntimeEnvironmentId: null }, 'GitHub')).toEqual({
label: getLocalExecutionHostLabel(),
label: LOCAL_HOST_LABEL,
description:
'GitHub API budget is fetched from the CLI on this desktop client. Use Settings > Remote Orca Servers > Advanced to view server-owned budgets.'
})
@@ -1,8 +1,10 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import { GitHubRateLimitPanel } from '@/components/github/github-rate-limit-display'
import { GitLabRateLimitPanel } from '@/components/gitlab/gitlab-rate-limit-display'
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
type StoreState = {
settings: { activeRuntimeEnvironmentId: string | null }
@@ -19,7 +21,6 @@ const mocks = vi.hoisted(() => ({
} as StoreState
}
}))
const localHostLabel = getLocalExecutionHostLabel()
vi.mock('@/store', () => ({
useAppStore: (selector: (state: StoreState) => unknown) => selector(mocks.store.current)
@@ -35,7 +36,7 @@ describe('provider rate-limit panels account scope', () => {
const markup = renderToStaticMarkup(<GitHubRateLimitPanel />)
expect(markup).toContain(`Budget scope: ${localHostLabel}`)
expect(markup).toContain(`Budget scope: ${LOCAL_HOST_LABEL}`)
expect(markup).toContain(
'GitHub API budget is fetched from the CLI on this desktop client. Use Settings &gt; Remote Orca Servers &gt; Advanced to view server-owned budgets.'
)
@@ -3,10 +3,12 @@
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context'
import { LinearIntegrationCard } from './task-tracker-integration-cards'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
type StoreState = {
linearStatus: {
connected: boolean
@@ -46,7 +48,6 @@ vi.mock('@/components/linear-api-key-dialog', () => ({
let root: Root | null = null
let container: HTMLDivElement | null = null
const localHostLabel = getLocalExecutionHostLabel()
function installStore(
connected: boolean,
@@ -108,7 +109,7 @@ describe('LinearIntegrationCard account scope', () => {
const rendered = await renderCard()
expect(rendered.textContent).toContain(`Account scope: ${localHostLabel}`)
expect(rendered.textContent).toContain(`Account scope: ${LOCAL_HOST_LABEL}`)
expect(rendered.textContent).toContain(
'Credentials and account checks for this provider are owned by this desktop client. Use Settings > Remote Orca Servers > Advanced to edit server-owned credentials.'
)
@@ -571,8 +571,8 @@ function getHostHeaderDetail(row: HostHeaderRow): { text: string; isWarning: boo
isWarning: false
}
}
// Why: the transport suffix only earns space on remote hosts; "This
// computer" on Local Mac is noise.
// Why: the transport suffix only earns space on remote hosts; repeating
// "This computer" under the local host label is noise.
if (row.kind !== 'local') {
return { text: row.detail, isWarning: false }
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import {
buildSidebarHostOptions,
buildSidebarHostScopeOptions,
@@ -8,6 +8,8 @@ import {
shouldShowHostScopeControls
} from './sidebar-host-options'
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
describe('sidebar host options', () => {
it('hides host controls for local-only workspaces', () => {
const hosts = buildSidebarHostOptions({
@@ -19,7 +21,7 @@ describe('sidebar host options', () => {
expect(hosts).toEqual([
{
id: 'local',
label: getLocalExecutionHostLabel(),
label: LOCAL_HOST_LABEL,
detail: 'This computer',
kind: 'local',
health: 'local',
@@ -174,13 +176,8 @@ describe('sidebar host options', () => {
})
expect(buildSidebarHostScopeOptions(hosts)).toMatchObject([
{
id: 'all',
label: 'All hosts',
detail: `${getLocalExecutionHostLabel()}, Builder`,
health: 'mixed'
},
{ id: 'local', label: getLocalExecutionHostLabel(), health: 'local' },
{ id: 'all', label: 'All hosts', detail: `${LOCAL_HOST_LABEL}, Builder`, health: 'mixed' },
{ id: 'local', label: LOCAL_HOST_LABEL, health: 'local' },
{ id: 'ssh:ssh-1', label: 'Builder', health: 'disconnected' }
])
})
@@ -2,7 +2,7 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
import { getExecutionHostLabel } from '../../../../shared/execution-host'
import {
ALL_GROUP_META,
buildRows,
@@ -24,7 +24,7 @@ import type {
WorktreeLineage
} from '../../../../shared/types'
const localHostLabel = getLocalExecutionHostLabel()
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
const repo: Repo = {
id: 'repo-1',
@@ -349,7 +349,7 @@ describe('buildRows with pinned worktrees', () => {
expect(rows).toMatchObject([
{ type: 'header', key: 'project:github:stablyai/orca', label: 'Orca', count: 2 },
{ type: 'item', worktree: { id: worktree.id }, hostContextLabel: localHostLabel },
{ type: 'item', worktree: { id: worktree.id }, hostContextLabel: LOCAL_HOST_LABEL },
{ type: 'item', worktree: { id: remoteWorktree.id }, hostContextLabel: 'gpu-vm' }
])
})
@@ -464,14 +464,14 @@ describe('buildRows with pinned worktrees', () => {
{ projects: [project], projectHostSetups: [projectHostSetups[0]!, runtimeSetup] },
[],
new Map([
['local', localHostLabel],
['local', LOCAL_HOST_LABEL],
['runtime:03ef704c-b180-4b10-998d-e28fbd5de9a3', 'dev box']
])
)
expect(rows).toMatchObject([
{ type: 'header', key: 'project:github:stablyai/orca', label: 'Orca', count: 2 },
{ type: 'item', worktree: { id: worktree.id }, hostContextLabel: localHostLabel },
{ type: 'item', worktree: { id: worktree.id }, hostContextLabel: LOCAL_HOST_LABEL },
{ type: 'item', worktree: { id: runtimeWorktree.id }, hostContextLabel: 'dev box' }
])
})
@@ -1,11 +1,11 @@
import { describe, expect, it } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../shared/execution-host'
import {
getTaskSourceAvailabilityNotice,
getTaskSourceContextSummary
} from './task-source-context-summary'
import { getExecutionHostLabel } from '../../../shared/execution-host'
const localHostLabel = getLocalExecutionHostLabel()
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
describe('task source context summary', () => {
it('shows provider, host, and provider identity for a single repo-backed source', () => {
@@ -59,9 +59,9 @@ describe('task source context summary', () => {
]
})
expect(summary.label).toBe(`GitHub · ${localHostLabel}, builder · personal-gh, work-gh`)
expect(summary.label).toBe(`GitHub · ${LOCAL_HOST_LABEL}, builder · personal-gh, work-gh`)
expect(summary.title).toBe(
`GitHub · Host: ${localHostLabel}, builder · Account: personal-gh, work-gh · Source: stablyai/orca · 2 selected projects`
`GitHub · Host: ${LOCAL_HOST_LABEL}, builder · Account: personal-gh, work-gh · Source: stablyai/orca · 2 selected projects`
)
})
@@ -152,9 +152,9 @@ describe('task source context summary', () => {
]
})
expect(summary.label).toBe(`GitLab · ${localHostLabel} +2 · 3 projects`)
expect(summary.label).toBe(`GitLab · ${LOCAL_HOST_LABEL} +2 · 3 projects`)
expect(summary.title).toBe(
`GitLab · Host: ${localHostLabel}, build, linux · 3 selected projects`
`GitLab · Host: ${LOCAL_HOST_LABEL}, build, linux · 3 selected projects`
)
})
@@ -280,7 +280,7 @@ describe('task source context summary', () => {
accountHostId: 'local',
linearWorkspaceName: 'Stably'
}).label
).toBe(`Linear · ${localHostLabel} · Stably`)
).toBe(`Linear · ${LOCAL_HOST_LABEL} · Stably`)
expect(
getTaskSourceContextSummary({
@@ -1,7 +1,11 @@
import type { HostedReviewProvider } from '../../../shared/hosted-review'
import {
resolveHostedReviewCreationProvider,
type HostedReviewCreationProvider
} from '../../../shared/hosted-review-creation-providers'
import { translate } from '@/i18n/i18n'
export type SupportedHostedReviewCopyProvider = 'github' | 'gitlab'
export type SupportedHostedReviewCopyProvider = HostedReviewCreationProvider
export type LocalizedHostedReviewCopy = {
shortLabel: string
@@ -13,7 +17,7 @@ export type LocalizedHostedReviewCopy = {
export function resolveSupportedHostedReviewCopyProvider(
provider: HostedReviewProvider | null | undefined
): SupportedHostedReviewCopyProvider {
return provider === 'gitlab' ? 'gitlab' : 'github'
return resolveHostedReviewCreationProvider(provider)
}
export function localizedHostedReviewCopy(
@@ -27,6 +31,22 @@ export function localizedHostedReviewCopy(
providerName: translate('auto.i18n.hostedReview.copy.91b5c8d7e6', 'GitLab')
}
}
if (provider === 'azure-devops') {
return {
shortLabel: translate('auto.i18n.hostedReview.copy.f0a4b8c2d1', 'PR'),
reviewLabel: translate('auto.i18n.hostedReview.copy.e9f3a7b1c0', 'pull request'),
titleLabel: translate('auto.i18n.hostedReview.copy.d8e2f6a0b9', 'Pull Request'),
providerName: 'Azure DevOps'
}
}
if (provider === 'gitea') {
return {
shortLabel: translate('auto.i18n.hostedReview.copy.f0a4b8c2d1', 'PR'),
reviewLabel: translate('auto.i18n.hostedReview.copy.e9f3a7b1c0', 'pull request'),
titleLabel: translate('auto.i18n.hostedReview.copy.d8e2f6a0b9', 'Pull Request'),
providerName: 'Gitea'
}
}
return {
shortLabel: translate('auto.i18n.hostedReview.copy.f0a4b8c2d1', 'PR'),
reviewLabel: translate('auto.i18n.hostedReview.copy.e9f3a7b1c0', 'pull request'),
+24 -3
View File
@@ -8366,14 +8366,32 @@
"e2b7a1c0d9f4": "Failed to create {{value0}}",
"hugeRepoIgnorePrompt": "This repository has too many active changes. Add \"{{value0}}\" to .gitignore?",
"hugeRepoIgnoreAction": "Add to .gitignore",
"tooManyChanges": "Too many changes detected. Only the first {{value0}} are shown.",
"tooManyChanges": "Too many changes detected. Only the first {{value0}} changes are shown.",
"bf5082de46": "{{value0}} copied",
"c06193ef57": "Failed to copy {{value0}}",
"d172a4f068": "Commit hash",
"e283b50179": "Commit message",
"f394c6128a": "No agent available to explain this commit",
"04a5d7239b": "This repository has no supported web remote",
"15b6e834ac": "Failed to open commit in browser"
"15b6e834ac": "Failed to open commit in browser",
"d37e68f61d": "Preparing branch for review…",
"8d8f5c6c94": "Generating commit message…",
"fda060d6ce": "Review the commit message, then retry Create PR.",
"b75cb1fd0c": "Committing changes…",
"995c5e67ec": "Review setup needs attention.",
"d7492cafce": "Could not refresh Source Control. Retry Create PR.",
"473f18758e": "Source Control AI settings",
"createPrIntentConfigureAi": "Add a commit message or configure Source Control AI settings.",
"createPrIntentGenerateFailed": "Could not generate a commit message. Add one and retry.",
"createPrIntentCommitFailed": "Could not commit changes. Fix the issue, then retry Create PR.",
"createPrIntentNeedsSync": "Sync this branch before creating a review.",
"createPrIntentBranchNotReady": "Branch is not ready to create a review yet.",
"createPrIntentPublishing": "Publishing branch…",
"createPrIntentForcePushing": "Force pushing with lease…",
"createPrIntentPushing": "Pushing commits…",
"createPrIntentRemoteFailed": "Could not update the remote branch. Retry Create PR.",
"createPrIntentGeneratingDetails": "Generating review details…",
"createPrIntentCreatingReview": "Creating review…"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "Could not start the selected agent.",
@@ -8660,7 +8678,10 @@
"484f45c439": "{{value0}} in progress…",
"74fc171e99": "Force Push in progress…",
"16aee3a5c1": "Commit in progress…",
"e61b0d7a3c": "Check out a branch before publishing commits."
"e61b0d7a3c": "Check out a branch before publishing commits.",
"8c6d15a07d": "Create PR",
"d37e68f61d": "Preparing branch for review…",
"c72e5e65d1": "Prepare this branch and create a {{value0}}"
}
}
}
+23 -2
View File
@@ -8373,7 +8373,25 @@
"e283b50179": "Mensaje del commit",
"f394c6128a": "No hay ningún agente disponible para explicar este commit",
"04a5d7239b": "Este repositorio no tiene un remoto web compatible",
"15b6e834ac": "No se pudo abrir el commit en el navegador"
"15b6e834ac": "No se pudo abrir el commit en el navegador",
"d37e68f61d": "Preparando la rama para revisión…",
"8d8f5c6c94": "Generando mensaje de commit…",
"fda060d6ce": "Revisa el mensaje de commit y vuelve a intentar Crear PR.",
"b75cb1fd0c": "Confirmando cambios…",
"995c5e67ec": "La configuración de revisión necesita atención.",
"d7492cafce": "No se pudo actualizar Source Control. Vuelve a intentar Crear PR.",
"473f18758e": "Configuración de IA de Source Control",
"createPrIntentConfigureAi": "Agrega un mensaje de commit o configura la IA de Source Control.",
"createPrIntentGenerateFailed": "No se pudo generar un mensaje de commit. Agrega uno y vuelve a intentarlo.",
"createPrIntentCommitFailed": "No se pudieron confirmar los cambios. Corrige el problema y vuelve a intentar Crear PR.",
"createPrIntentNeedsSync": "Sincroniza esta rama antes de crear una revisión.",
"createPrIntentBranchNotReady": "La rama aún no está lista para crear una revisión.",
"createPrIntentPublishing": "Publicando rama…",
"createPrIntentForcePushing": "Haciendo force push con lease…",
"createPrIntentPushing": "Subiendo commits…",
"createPrIntentRemoteFailed": "No se pudo actualizar la rama remota. Vuelve a intentar Crear PR.",
"createPrIntentGeneratingDetails": "Generating review details…",
"createPrIntentCreatingReview": "Creating review…"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "No se pudo iniciar el agente seleccionado.",
@@ -8660,7 +8678,10 @@
"484f45c439": "{{value0}} en progreso…",
"74fc171e99": "Empuje forzado en progreso...",
"16aee3a5c1": "Compromiso en progreso...",
"e61b0d7a3c": "Check out a branch before publishing commits."
"e61b0d7a3c": "Cambia a una rama antes de publicar commits.",
"8c6d15a07d": "Crear PR",
"d37e68f61d": "Preparando la rama para revisión…",
"c72e5e65d1": "Prepara esta rama y crea un {{value0}}"
}
}
}
+23 -2
View File
@@ -8373,7 +8373,25 @@
"e283b50179": "コミットメッセージ",
"f394c6128a": "このコミットを説明できるエージェントがありません",
"04a5d7239b": "このリポジトリには対応するWebリモートがありません",
"15b6e834ac": "コミットをブラウザーで開けませんでした"
"15b6e834ac": "コミットをブラウザーで開けませんでした",
"d37e68f61d": "レビュー用にブランチを準備中…",
"8d8f5c6c94": "コミットメッセージを生成中…",
"fda060d6ce": "コミットメッセージを確認してから、Create PR を再試行してください。",
"b75cb1fd0c": "変更をコミット中…",
"995c5e67ec": "レビュー設定の確認が必要です。",
"d7492cafce": "Source Control を更新できませんでした。Create PR を再試行してください。",
"473f18758e": "Source Control AI 設定",
"createPrIntentConfigureAi": "コミットメッセージを追加するか、Source Control AI 設定を構成してください。",
"createPrIntentGenerateFailed": "コミットメッセージを生成できませんでした。追加してから再試行してください。",
"createPrIntentCommitFailed": "変更をコミットできませんでした。問題を修正してから Create PR を再試行してください。",
"createPrIntentNeedsSync": "レビューを作成する前にこのブランチを同期してください。",
"createPrIntentBranchNotReady": "このブランチはまだレビューを作成できる状態ではありません。",
"createPrIntentPublishing": "ブランチを公開中…",
"createPrIntentForcePushing": "lease 付きで強制プッシュ中…",
"createPrIntentPushing": "コミットをプッシュ中…",
"createPrIntentRemoteFailed": "リモートブランチを更新できませんでした。Create PR を再試行してください。",
"createPrIntentGeneratingDetails": "Generating review details…",
"createPrIntentCreatingReview": "Creating review…"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "選択した agent を開始できませんでした。",
@@ -8660,7 +8678,10 @@
"484f45c439": "{{value0}} が進行中です…",
"74fc171e99": "強制プッシュ中です…",
"16aee3a5c1": "Commit 中です…",
"e61b0d7a3c": "Check out a branch before publishing commits."
"e61b0d7a3c": "コミットを公開する前にブランチをチェックアウトしてください。",
"8c6d15a07d": "PR を作成",
"d37e68f61d": "レビュー用にブランチを準備中…",
"c72e5e65d1": "このブランチを準備して {{value0}} を作成"
}
}
}
+23 -2
View File
@@ -8373,7 +8373,25 @@
"e283b50179": "커밋 메시지",
"f394c6128a": "이 커밋을 설명할 에이전트가 없습니다",
"04a5d7239b": "이 저장소에는 지원되는 웹 원격이 없습니다",
"15b6e834ac": "브라우저에서 커밋을 열지 못했습니다"
"15b6e834ac": "브라우저에서 커밋을 열지 못했습니다",
"d37e68f61d": "검토를 위해 브랜치를 준비하는 중…",
"8d8f5c6c94": "커밋 메시지를 생성하는 중…",
"fda060d6ce": "커밋 메시지를 검토한 다음 Create PR을 다시 시도하세요.",
"b75cb1fd0c": "변경 사항을 커밋하는 중…",
"995c5e67ec": "검토 설정에 확인이 필요합니다.",
"d7492cafce": "Source Control을 새로 고칠 수 없습니다. Create PR을 다시 시도하세요.",
"473f18758e": "Source Control AI 설정",
"createPrIntentConfigureAi": "커밋 메시지를 추가하거나 Source Control AI 설정을 구성하세요.",
"createPrIntentGenerateFailed": "커밋 메시지를 생성할 수 없습니다. 메시지를 추가한 다음 다시 시도하세요.",
"createPrIntentCommitFailed": "변경 사항을 커밋할 수 없습니다. 문제를 수정한 다음 Create PR을 다시 시도하세요.",
"createPrIntentNeedsSync": "리뷰를 만들기 전에 이 브랜치를 동기화하세요.",
"createPrIntentBranchNotReady": "브랜치가 아직 리뷰를 만들 준비가 되지 않았습니다.",
"createPrIntentPublishing": "브랜치를 게시하는 중…",
"createPrIntentForcePushing": "lease로 강제 푸시하는 중…",
"createPrIntentPushing": "커밋을 푸시하는 중…",
"createPrIntentRemoteFailed": "원격 브랜치를 업데이트할 수 없습니다. Create PR을 다시 시도하세요.",
"createPrIntentGeneratingDetails": "Generating review details…",
"createPrIntentCreatingReview": "Creating review…"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "선택한 agent를 시작할 수 없습니다.",
@@ -8660,7 +8678,10 @@
"484f45c439": "{{value0}} 진행 중…",
"74fc171e99": "강제 푸시 진행 중…",
"16aee3a5c1": "Commit 진행 중…",
"e61b0d7a3c": "Check out a branch before publishing commits."
"e61b0d7a3c": "커밋을 게시하기 전에 브랜치를 체크아웃하세요.",
"8c6d15a07d": "PR 생성",
"d37e68f61d": "검토를 위해 브랜치를 준비하는 중…",
"c72e5e65d1": "이 브랜치를 준비하고 {{value0}} 생성"
}
}
}
+23 -2
View File
@@ -8373,7 +8373,25 @@
"e283b50179": "提交信息",
"f394c6128a": "没有可用于解释此提交的代理",
"04a5d7239b": "此仓库没有受支持的网页远程库",
"15b6e834ac": "无法在浏览器中打开提交"
"15b6e834ac": "无法在浏览器中打开提交",
"d37e68f61d": "正在准备分支以供评审…",
"8d8f5c6c94": "正在生成 commit 消息…",
"fda060d6ce": "请先检查 commit 消息,然后重试创建 PR。",
"b75cb1fd0c": "正在提交更改…",
"995c5e67ec": "评审设置需要处理。",
"d7492cafce": "无法刷新 Source Control。请重试创建 PR。",
"473f18758e": "Source Control AI 设置",
"createPrIntentConfigureAi": "请添加 commit 消息或配置 Source Control AI 设置。",
"createPrIntentGenerateFailed": "无法生成 commit 消息。请添加一条消息后重试。",
"createPrIntentCommitFailed": "无法提交更改。请修复问题后重试创建 PR。",
"createPrIntentNeedsSync": "创建评审前请先同步此分支。",
"createPrIntentBranchNotReady": "分支尚未准备好创建评审。",
"createPrIntentPublishing": "正在发布分支…",
"createPrIntentForcePushing": "正在使用 lease 强制推送…",
"createPrIntentPushing": "正在推送 commits…",
"createPrIntentRemoteFailed": "无法更新远程分支。请重试创建 PR。",
"createPrIntentGeneratingDetails": "Generating review details…",
"createPrIntentCreatingReview": "Creating review…"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "无法启动选定的 Agent。",
@@ -8660,7 +8678,10 @@
"484f45c439": "{{value0}} 正在进行中...",
"74fc171e99": "强制推送正在进行中...",
"16aee3a5c1": "正在进行中……",
"e61b0d7a3c": "Check out a branch before publishing commits."
"e61b0d7a3c": "请先检出分支再发布 commits。",
"8c6d15a07d": "创建 PR",
"d37e68f61d": "正在准备分支以供评审…",
"c72e5e65d1": "准备此分支并创建 {{value0}}"
}
}
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { getLocalExecutionHostLabel, type ExecutionHostId } from '../../../shared/execution-host'
import { getExecutionHostLabel, type ExecutionHostId } from '../../../shared/execution-host'
import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry'
import {
PROJECT_HOST_SETUP_RUNTIME_CAPABILITY,
@@ -12,7 +12,8 @@ const FULL_HOST_MODEL_RUNTIME_CAPABILITIES = [
PROJECT_HOST_SETUP_RUNTIME_CAPABILITY,
WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY
]
const localHostLabel = getLocalExecutionHostLabel()
const LOCAL_HOST_LABEL = getExecutionHostLabel('local')
function repo(id: string): Repo {
return {
@@ -53,7 +54,7 @@ function host(
return {
id,
kind: id === 'local' ? 'local' : id.startsWith('ssh:') ? 'ssh' : 'runtime',
label: id === 'local' ? localHostLabel : id.replace(/^ssh:|^runtime:/, ''),
label: id === 'local' ? LOCAL_HOST_LABEL : id.replace(/^ssh:|^runtime:/, ''),
detail: id === 'local' ? 'This computer' : 'Host',
health: id === 'local' ? 'local' : 'available',
...overrides
@@ -72,7 +73,7 @@ describe('buildProjectHostSetupOptions', () => {
})
expect(options.map((option) => option.id)).toEqual(['local', 'remote'])
expect(options[0]).toMatchObject({ label: localHostLabel, repoId: 'local-repo' })
expect(options[0]).toMatchObject({ label: LOCAL_HOST_LABEL, repoId: 'local-repo' })
expect(options[1]).toMatchObject({ label: 'builder', repoId: 'remote-repo' })
})
@@ -131,7 +132,7 @@ describe('buildProjectHostSetupOptions', () => {
})
expect(options).toEqual([
expect.objectContaining({ id: 'local', kind: 'ready', label: localHostLabel }),
expect.objectContaining({ id: 'local', kind: 'ready', label: LOCAL_HOST_LABEL }),
expect.objectContaining({
id: 'needs-setup:ssh:builder',
kind: 'needs-setup',
@@ -164,7 +165,7 @@ describe('buildProjectHostSetupOptions', () => {
})
expect(options).toEqual([
expect.objectContaining({ id: 'local', kind: 'ready', label: localHostLabel }),
expect.objectContaining({ id: 'local', kind: 'ready', label: LOCAL_HOST_LABEL }),
expect.objectContaining({
id: 'needs-setup:runtime:gpu',
kind: 'needs-setup',
@@ -259,7 +260,7 @@ describe('buildProjectHostSetupOptions', () => {
})
expect(options).toEqual([
expect.objectContaining({ id: 'local', kind: 'ready', label: localHostLabel }),
expect.objectContaining({ id: 'local', kind: 'ready', label: LOCAL_HOST_LABEL }),
expect.objectContaining({
id: 'needs-setup:runtime:gpu',
kind: 'needs-setup',
@@ -13,7 +13,7 @@ export type SourceControlGenerationPlanResult =
const SYNTHETIC_COMMIT_PROMPT =
'Generate a concise git commit message for a synthetic dry-run diff. Return only the commit message.'
const SYNTHETIC_PULL_REQUEST_PROMPT =
'Generate a hosted review title and description for a synthetic branch diff. Return structured pull request fields.'
'Generate a hosted review title and description for a synthetic branch diff. Preserve any existing pull request or merge request template in the current description. Return structured pull request fields.'
const SYNTHETIC_TEXT_GENERATION_CONTEXT: Record<
SourceControlTextActionId,
+11 -1
View File
@@ -17,6 +17,7 @@ import type {
CommitMessageAgentCapability,
CommitMessageModelCapability
} from '../../../shared/commit-message-agent-spec'
import type { HostedReviewProvider } from '../../../shared/hosted-review'
import type { ResolvedSourceControlAiGenerationParams } from '../../../shared/source-control-ai'
import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../shared/commit-message-host-key'
import type { GitHistoryOptions, GitHistoryResult } from '../../../shared/git-history'
@@ -37,6 +38,15 @@ export type RuntimeGeneratePullRequestFieldsResult =
}
| { success: false; error: string; canceled?: boolean; branchChangedByPreparation?: boolean }
export type RuntimePullRequestGenerationInput = {
base: string
title: string
body: string
draft: boolean
provider?: HostedReviewProvider
useTemplate?: boolean
}
type RuntimeGitSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> &
Partial<
Pick<
@@ -608,7 +618,7 @@ export async function cancelRuntimeGenerateCommitMessage(
export async function generateRuntimePullRequestFields(
context: RuntimeGitContext,
input: { base: string; title: string; body: string; draft: boolean },
input: RuntimePullRequestGenerationInput,
overrides?: RuntimeGeneratePullRequestFieldsOverrides
): Promise<RuntimeGeneratePullRequestFieldsResult> {
const target = getActiveRuntimeTarget(context.settings)