Implement automated git preparation workflow for mobile PR creation

Introduce a structured hosted review intent preparation workflow to handle
staging, AI commit message generation, committing, and pushing changes
automatically before displaying the pull request composer on mobile.

- Map creation block reasons to descriptive user-facing validation errors
  (e.g., dirty working tree, default branch, detached head) to match desktop.
- Decouple hosted-review business logic into a dedicated service helper.
- Update source control runner hooks to handle the new preparation flow.
This commit is contained in:
Jinjing
2026-06-27 14:49:10 -07:00
parent c0f4bdd020
commit 7d229f58f1
21 changed files with 1365 additions and 402 deletions
+2 -1
View File
@@ -8,7 +8,7 @@ import { MobilePrViewPanel } from '../../../../src/components/pr-sidebar/MobileP
export default function MobilePrViewScreen() {
const { hostId, worktreeId } = useLocalSearchParams<{ hostId: string; worktreeId: string }>()
const { client, state: connState } = useHostClient(hostId)
const { branch, headSha, isGithubRepo, repoLoaded, loaded } = useMobilePrBranchContext({
const { branch, headSha, status, isGithubRepo, repoLoaded, loaded } = useMobilePrBranchContext({
client,
connState,
worktreeId
@@ -21,6 +21,7 @@ export default function MobilePrViewScreen() {
worktreeId={worktreeId}
branch={branch}
headSha={headSha}
gitStatus={status}
isGithubRepo={isGithubRepo}
branchContextLoaded={loaded && repoLoaded}
embedded={false}
@@ -897,6 +897,7 @@ export default function SessionScreen() {
const {
branch: prBranch,
headSha: prHeadSha,
status: prStatus,
isGithubRepo: prIsGithubRepo,
repoLoaded: prRepoContextLoaded,
loaded: prContextLoaded
@@ -5126,6 +5127,7 @@ export default function SessionScreen() {
connState={connState}
branch={prBranch}
headSha={prHeadSha}
gitStatus={prStatus}
isGithubRepo={prIsGithubRepo}
branchContextLoaded={prContextLoaded && prRepoContextLoaded}
availableWidth={sessionContentRowWidth}
-26
View File
@@ -1721,56 +1721,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.52.0':
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.52.0':
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.52.0':
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.52.0':
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
@@ -1843,56 +1835,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.67.0':
resolution: {integrity: sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.67.0':
resolution: {integrity: sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.67.0':
resolution: {integrity: sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.67.0':
resolution: {integrity: sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.67.0':
resolution: {integrity: sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.67.0':
resolution: {integrity: sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.67.0':
resolution: {integrity: sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.67.0':
resolution: {integrity: sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g==}
@@ -2354,42 +2338,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0':
resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.0':
resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.0':
resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.0':
resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0':
resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0':
resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==}
@@ -4651,28 +4629,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -32,6 +32,7 @@ export function MobileDiffReviewScreenView({ controller, onBack }: Props) {
// Inline-dock the sidebar only when wide and the repo is GitHub; otherwise it
// lives in the RightDrawer overlay toggled by showPRSidebar.
const showInlineDock = presentationMode === 'inline' && controller.prSidebarIsGithubRepo
const gitStatus = controller.screenState.kind === 'ready' ? controller.screenState.status : null
// The docked sidebar has no trigger to tap, so load its PR data once it becomes
// visible (the overlay loads on trigger press instead).
@@ -120,6 +121,7 @@ export function MobileDiffReviewScreenView({ controller, onBack }: Props) {
connState={controller.connState}
worktreeId={controller.worktreeId}
gitBranch={controller.prSidebarBranch}
gitStatus={gitStatus}
headSha={controller.prSidebarHeadSha}
bottomInset={insets.bottom}
/>
@@ -140,6 +142,7 @@ export function MobileDiffReviewScreenView({ controller, onBack }: Props) {
connState={controller.connState}
worktreeId={controller.worktreeId}
gitBranch={controller.prSidebarBranch}
gitStatus={gitStatus}
headSha={controller.prSidebarHeadSha}
/>
</RightDrawer>
+8 -12
View File
@@ -17,6 +17,7 @@ import { useMobilePrAiTriage, type MobilePrAiTriage } from '../session/use-mobil
import { buildFixChecksPrompt, buildResolveConflictsPrompt } from '../session/pr-ai-triage-prompt'
import { prSidebarRenderBranch } from './mobile-pr-sidebar-presentation'
import { mobilePrSidebarStyles as styles } from './pr-sidebar/mobile-pr-sidebar-styles'
import type { MobileGitStatusResult } from '../source-control/mobile-git-status'
import { PRSidebarHeader } from './pr-sidebar/PRSidebarHeader'
import { PRConflictingFilesSection } from './pr-sidebar/PRConflictingFilesSection'
import { PRActionsSection } from './pr-sidebar/PRActionsSection'
@@ -34,17 +35,13 @@ type Props = {
client: RpcClient | null
connState: ConnectionState
worktreeId: string
// Current git branch — feeds the create-PR prefill in the no-PR empty state.
gitBranch: string | null
gitStatus: MobileGitStatusResult | null
headSha: string | null
// Applied by the docked column so content clears the home indicator (the screen's
// SafeAreaView is edges={['top']} only).
bottomInset?: number
}
// The shell switches on the controller's state machine and renders the sections
// (header/actions/reviewers/checks). The mutation hook is created here (hooks must
// run unconditionally) and only fires once a PR is ready. Style only from mobile-theme.
// Mutation hooks run unconditionally here and gate internally until a PR is ready.
export function MobilePRSidebar({
state,
onRetry,
@@ -53,6 +50,7 @@ export function MobilePRSidebar({
connState,
worktreeId,
gitBranch,
gitStatus,
headSha,
bottomInset = 0
}: Props) {
@@ -74,8 +72,6 @@ export function MobilePRSidebar({
prRepo,
refetch
})
// Separate hook for the interactive comment timeline (reply/resolve/add). Like
// useMobilePrActions it must run unconditionally; it gates internally on a client.
const commentActions = useMobilePrCommentActions({
client,
connState,
@@ -84,8 +80,6 @@ export function MobilePRSidebar({
prRepo,
refetch
})
// Inline title-edit action. Like the others it must run unconditionally and gates
// internally on a client; refetches authoritative PR data after a successful edit.
const titleAction = useMobilePrTitleAction({
client,
connState,
@@ -94,8 +88,6 @@ export function MobilePRSidebar({
prRepo,
refetch
})
// AI triage (Fix checks / Resolve conflicts). Like the other hooks it must run
// unconditionally; it gates internally on a connected client.
const triage = useMobilePrAiTriage({ client, connState, worktreeId })
return (
@@ -113,6 +105,7 @@ export function MobilePRSidebar({
client={client}
worktreeId={worktreeId}
gitBranch={gitBranch}
gitStatus={gitStatus}
actions={actions}
commentActions={commentActions}
titleAction={titleAction}
@@ -130,6 +123,7 @@ function PrSidebarContent({
client,
worktreeId,
gitBranch,
gitStatus,
actions,
commentActions,
titleAction,
@@ -142,6 +136,7 @@ function PrSidebarContent({
client: RpcClient | null
worktreeId: string
gitBranch: string | null
gitStatus: MobileGitStatusResult | null
actions: MobilePrActions
commentActions: MobilePrCommentActions
titleAction: MobilePrTitleAction
@@ -194,6 +189,7 @@ function PrSidebarContent({
client={client}
worktreeId={worktreeId}
gitBranch={gitBranch}
gitStatus={gitStatus}
onCreated={refetch}
/>
)
@@ -8,12 +8,15 @@ import {
TriangleAlert,
X
} from 'lucide-react-native'
import type { HostedReviewProvider } from '../../../../src/shared/hosted-review'
import { colors } from '../../theme/mobile-theme'
import type { RpcClient } from '../../transport/rpc-client'
import type { RpcSuccess } from '../../transport/types'
import { triggerError, triggerSuccess } from '../../platform/haptics'
import { createMobilePr } from '../../source-control/mobile-pr-create'
import {
createMobilePr,
shouldPushBeforeMobilePrCreate,
type MobilePrPrefill
} from '../../source-control/mobile-pr-create'
import { hostedReviewCopy } from '../../source-control/hosted-review-copy'
import {
getPrComposeDisabledReason,
@@ -22,12 +25,7 @@ import {
import { MobilePrBasePicker } from '../MobilePrBasePicker'
import { mobilePrComposeFormStyles as styles } from './mobile-pr-compose-form-styles'
export type PrComposePrefill = {
base: string
title: string
body: string
provider: HostedReviewProvider
}
export type PrComposePrefill = MobilePrPrefill
type Props = {
client: RpcClient | null
@@ -61,6 +59,7 @@ export function MobilePrComposeForm({
const [generating, setGenerating] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const pushBeforeCreate = shouldPushBeforeMobilePrCreate(prefill)
const generate = useCallback(async () => {
if (!client || generating) {
@@ -128,13 +127,18 @@ export function MobilePrComposeForm({
...(head ? { head } : {}),
title,
body,
draft
draft,
pushBeforeCreate
})
if (outcome.ok) {
triggerSuccess()
const warning = outcome.linkError
? `${copy.titleLabel} created, but Orca could not refresh it yet.`
: undefined
const warning = outcome.existing
? outcome.number
? `${copy.titleLabel} #${outcome.number} is already open.`
: `${copy.titleLabel} is already open.`
: outcome.linkError
? `${copy.titleLabel} created, but Orca could not refresh it yet.`
: undefined
if (warning) {
setError(warning)
}
@@ -156,6 +160,7 @@ export function MobilePrComposeForm({
head,
onCreated,
prefill.provider,
pushBeforeCreate,
submitting,
title,
worktreeId
@@ -279,7 +284,13 @@ export function MobilePrComposeForm({
<ReviewIcon size={14} color={colors.bgBase} strokeWidth={2.2} />
)}
<Text style={styles.submitText}>
{draft ? `Create draft ${copy.shortLabel}` : `Create ${copy.shortLabel}`}
{pushBeforeCreate
? draft
? `Push & create draft ${copy.shortLabel}`
: `Push & create ${copy.shortLabel}`
: draft
? `Create draft ${copy.shortLabel}`
: `Create ${copy.shortLabel}`}
</Text>
</Pressable>
</View>
@@ -6,6 +6,7 @@ import { ChevronLeft, X } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
import type { ConnectionState } from '../../transport/types'
import type { RpcClient } from '../../transport/rpc-client'
import type { MobileGitStatusResult } from '../../source-control/mobile-git-status'
import { useMobilePrSidebarController } from '../../session/use-mobile-pr-sidebar-controller'
import { MobilePRSidebar } from '../MobilePRSidebar'
@@ -15,6 +16,7 @@ type Props = {
worktreeId: string
branch: string | null
headSha: string | null
gitStatus: MobileGitStatusResult | null
isGithubRepo?: boolean
branchContextLoaded?: boolean
// Embedded (docked) drops the full-screen SafeAreaView chrome and shows a close
@@ -29,6 +31,7 @@ export function MobilePrViewPanel({
worktreeId,
branch,
headSha,
gitStatus,
isGithubRepo = true,
branchContextLoaded = true,
embedded = false,
@@ -79,6 +82,7 @@ export function MobilePrViewPanel({
connState={connState}
worktreeId={worktreeId}
gitBranch={branch}
gitStatus={gitStatus}
headSha={headSha}
bottomInset={insets.bottom}
/>
@@ -3,7 +3,15 @@ import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { GitPullRequestArrow, Link2, RefreshCw } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import type { RpcClient } from '../../transport/rpc-client'
import { resolveMobilePrPrefill, type MobilePrPrefill } from '../../source-control/mobile-pr-create'
import {
getMobilePrCreateBlockMessage,
type MobilePrPrefill
} from '../../source-control/mobile-pr-create'
import type { MobileGitStatusResult } from '../../source-control/mobile-git-status'
import {
mobileHostedReviewCreateIntentProgressMessage,
prepareMobileHostedReviewCreateIntent
} from '../../source-control/mobile-hosted-review-create-intent'
import { fetchWorktreeLinkedPR } from '../../source-control/mobile-pr-link'
import { openMobilePrUrl } from '../MobilePrComposeSheet'
import { MobilePrComposeForm } from './MobilePrComposeForm'
@@ -14,6 +22,7 @@ type Props = {
client: RpcClient | null
worktreeId: string
gitBranch: string | null
gitStatus: MobileGitStatusResult | null
// Refetches the sidebar after create or an explicit empty-state refresh.
onCreated: () => void
}
@@ -23,7 +32,13 @@ type Mode = 'choose' | 'create' | 'link'
// Empty state for a branch with no PR: create a new PR, or link an existing one
// (the no-PR surface is the natural home for linking — desktop's link entry lives
// on its PR card, but on mobile this is where a user lands with nothing linked).
export function PrSidebarCreateEmptyState({ client, worktreeId, gitBranch, onCreated }: Props) {
export function PrSidebarCreateEmptyState({
client,
worktreeId,
gitBranch,
gitStatus,
onCreated
}: Props) {
const [prefill, setPrefill] = useState<MobilePrPrefill | null>(null)
const [mode, setMode] = useState<Mode>('choose')
const [loading, setLoading] = useState(false)
@@ -61,17 +76,27 @@ export function PrSidebarCreateEmptyState({ client, worktreeId, gitBranch, onCre
setCreateWarning(null)
setLoading(true)
try {
// Git-status fields are best-effort here (the sidebar has no working-tree
// state); base/title/body come from host eligibility regardless, and create
// does the authoritative branch-state validation.
const resolved = await resolveMobilePrPrefill(client, worktreeId, {
branch: gitBranch ?? undefined,
title: gitBranch ?? '',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 1,
behind: 0
if (!gitBranch) {
setCreateWarning('Check out a branch before creating a pull request.')
return
}
const prepared = await prepareMobileHostedReviewCreateIntent(client, worktreeId, {
branch: gitBranch,
title: gitBranch,
status: gitStatus,
onProgress: (progress) =>
setCreateWarning(mobileHostedReviewCreateIntentProgressMessage(progress))
})
if (!prepared.ok) {
setCreateWarning(prepared.error)
return
}
const resolved = prepared.prefill
const blockedMessage = getMobilePrCreateBlockMessage(resolved)
if (blockedMessage) {
setCreateWarning(blockedMessage)
return
}
setPrefill(resolved)
setMode('create')
} catch {
+6
View File
@@ -8,6 +8,7 @@ import { MobilePrViewPanel } from '../components/pr-sidebar/MobilePrViewPanel'
import { mobilePrSidebarStyles } from '../components/pr-sidebar/mobile-pr-sidebar-styles'
import { useMobileDockResize } from './use-mobile-dock-resize'
import type { ActivePanel } from './session-panel-host'
import type { MobileGitStatusResult } from '../source-control/mobile-git-status'
type Props = {
activePanel: Exclude<ActivePanel, null>
@@ -18,6 +19,7 @@ type Props = {
connState: ConnectionState
branch: string | null
headSha: string | null
gitStatus: MobileGitStatusResult | null
isGithubRepo: boolean
branchContextLoaded: boolean
availableWidth: number
@@ -40,6 +42,7 @@ export function SessionDockColumn({
connState,
branch,
headSha,
gitStatus,
isGithubRepo,
branchContextLoaded,
availableWidth,
@@ -60,6 +63,7 @@ export function SessionDockColumn({
connState={connState}
branch={branch}
headSha={headSha}
gitStatus={gitStatus}
isGithubRepo={isGithubRepo}
branchContextLoaded={branchContextLoaded}
onRequestClose={onRequestClose}
@@ -79,6 +83,7 @@ const DockPanelContent = memo(function DockPanelContent({
connState,
branch,
headSha,
gitStatus,
isGithubRepo,
branchContextLoaded,
onRequestClose
@@ -113,6 +118,7 @@ const DockPanelContent = memo(function DockPanelContent({
worktreeId={worktreeId}
branch={branch}
headSha={headSha}
gitStatus={gitStatus}
isGithubRepo={isGithubRepo}
branchContextLoaded={branchContextLoaded}
embedded
@@ -78,7 +78,7 @@ describe('deriveMobilePrBranchContext', () => {
it('does not throw on null status and null branchCompare', () => {
expect(() => deriveMobilePrBranchContext(null, null)).not.toThrow()
const result = deriveMobilePrBranchContext(null, null)
expect(result).toEqual({ branch: null, headSha: null })
expect(result).toEqual({ branch: null, headSha: null, status: null })
})
})
@@ -109,6 +109,7 @@ describe('loadMobilePrBranchContext', () => {
expect(out).toEqual({
branch: 'feat',
headSha: 'sha-status',
status: expect.objectContaining({ branch: 'feat', head: 'sha-status' }),
isGithubRepo: true,
repoLoaded: true,
loaded: true
@@ -10,6 +10,7 @@ import { readMobileBranchCompareResult, readMobileGitStatusResult } from './mobi
export type MobilePrBranchContext = {
branch: string | null
headSha: string | null
status: MobileGitStatusResult | null
isGithubRepo: boolean
repoLoaded: boolean
loaded: boolean
@@ -22,10 +23,11 @@ export type MobilePrBranchContext = {
export function deriveMobilePrBranchContext(
status: MobileGitStatusResult | null,
branchCompare: MobileGitBranchCompareResult | null
): { branch: string | null; headSha: string | null } {
): { branch: string | null; headSha: string | null; status: MobileGitStatusResult | null } {
return {
branch: status?.branch ?? null,
headSha: status?.head ?? branchCompare?.summary.headOid ?? null
headSha: status?.head ?? branchCompare?.summary.headOid ?? null,
status
}
}
@@ -41,6 +43,7 @@ export function useMobilePrBranchContext(input: {
const [context, setContext] = useState<MobilePrBranchContext>({
branch: null,
headSha: null,
status: null,
isGithubRepo: false,
repoLoaded: false,
loaded: false
@@ -54,6 +57,7 @@ export function useMobilePrBranchContext(input: {
setContext({
branch: null,
headSha: null,
status: null,
isGithubRepo: false,
repoLoaded: false,
loaded: false
@@ -63,6 +67,7 @@ export function useMobilePrBranchContext(input: {
setContext({
branch: null,
headSha: null,
status: null,
isGithubRepo: false,
repoLoaded: false,
loaded: false
@@ -108,6 +113,7 @@ export function useMobilePrBranchContext(input: {
...prev,
branch: null,
headSha: null,
status: null,
loaded: true
}))
}
@@ -142,7 +148,7 @@ export async function loadMobilePrRepoContext(
export async function loadMobilePrBranchIdentity(
client: RpcClient,
worktreeId: string
): Promise<Pick<MobilePrBranchContext, 'branch' | 'headSha'>> {
): Promise<Pick<MobilePrBranchContext, 'branch' | 'headSha' | 'status'>> {
const [status, branchCompare] = await Promise.all([
readGitStatus(client, worktreeId),
// Why: the standalone PR entry point only needs branchCompare as a head-SHA
@@ -0,0 +1,150 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
import { prepareMobileHostedReviewCreateIntent } from './mobile-hosted-review-create-intent'
function ok(result: unknown): RpcSuccess {
return { id: 'r', ok: true, result, _meta: { runtimeId: 'rt' } }
}
function fail(message: string): RpcFailure {
return { id: 'r', ok: false, error: { code: 'x', message }, _meta: { runtimeId: 'rt' } }
}
function status(entries: unknown[], upstreamStatus = { hasUpstream: true, ahead: 0, behind: 0 }) {
return {
entries,
conflictOperation: 'unknown',
branch: 'feature/x',
head: 'sha',
upstreamStatus
}
}
function entry(area: 'unstaged' | 'untracked' | 'staged') {
return { path: 'a.ts', status: 'modified', area }
}
function eligibility(overrides: Record<string, unknown>) {
return {
provider: 'github',
review: null,
defaultBaseRef: 'main',
title: 'feature/x',
body: '',
...overrides
}
}
function clientWith(responses: RpcResponse[]): Pick<RpcClient, 'sendRequest'> & {
calls: Array<{ method: string; params: unknown }>
} {
const calls: Array<{ method: string; params: unknown }> = []
return {
calls,
sendRequest: vi.fn(async (method: string, params?: unknown) => {
calls.push({ method, params })
return responses.shift() ?? fail(`unexpected ${method}`)
})
}
}
describe('prepareMobileHostedReviewCreateIntent', () => {
it('stages, generates a commit, commits, pushes, then returns an eligible prefill', async () => {
const client = clientWith([
ok(status([entry('unstaged')])),
ok({ success: true }),
ok(status([entry('staged')])),
ok({ success: true, message: 'Ship mobile PR create' }),
ok({ success: true }),
ok(status([], { hasUpstream: true, ahead: 1, behind: 0 })),
ok(eligibility({ canCreate: false, blockedReason: 'needs_push', nextAction: 'push' })),
ok({ success: true }),
ok(status([], { hasUpstream: true, ahead: 0, behind: 0 })),
ok(eligibility({ canCreate: true, blockedReason: null, nextAction: null }))
])
const progress: string[] = []
const result = await prepareMobileHostedReviewCreateIntent(client, 'repo-1::/tmp/wt', {
branch: 'feature/x',
title: 'feature/x',
status: null,
onProgress: (step) => progress.push(step)
})
expect(result).toEqual({
ok: true,
committed: true,
status: expect.objectContaining({
entries: [],
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }
}),
prefill: expect.objectContaining({ canCreate: true, blockedReason: null })
})
expect(progress).toEqual(['staging', 'generating_commit_message', 'committing', 'pushing'])
expect(client.calls.map((call) => call.method)).toEqual([
'git.status',
'git.bulkStage',
'git.status',
'git.generateCommitMessage',
'git.commit',
'git.status',
'hostedReview.getCreationEligibility',
'git.push',
'git.status',
'hostedReview.getCreationEligibility'
])
})
it('uses a provided commit message instead of generating one', async () => {
const client = clientWith([
ok(status([entry('staged')])),
ok({ success: true }),
ok(status([], { hasUpstream: true, ahead: 0, behind: 0 })),
ok(eligibility({ canCreate: true, blockedReason: null, nextAction: null }))
])
await expect(
prepareMobileHostedReviewCreateIntent(client, 'repo-1::/tmp/wt', {
branch: 'feature/x',
title: 'feature/x',
status: null,
commitMessage: 'Use my draft'
})
).resolves.toEqual(expect.objectContaining({ ok: true, committed: true }))
expect(client.calls.map((call) => call.method)).toEqual([
'git.status',
'git.commit',
'git.status',
'hostedReview.getCreationEligibility'
])
expect(client.calls[1].params).toEqual({
worktree: 'id:repo-1::/tmp/wt',
message: 'Use my draft'
})
})
it('returns an actionable error when commit message generation fails', async () => {
const client = clientWith([
ok(status([entry('staged')])),
ok({ success: false, error: 'no model configured' })
])
await expect(
prepareMobileHostedReviewCreateIntent(client, 'repo-1::/tmp/wt', {
branch: 'feature/x',
title: 'feature/x',
status: null
})
).resolves.toEqual({
ok: false,
error: 'Could not generate a commit message. Add one in Source Control, then retry.'
})
expect(client.calls.map((call) => call.method)).toEqual([
'git.status',
'git.generateCommitMessage'
])
})
})
@@ -0,0 +1,279 @@
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { readMobileGitStatusResult } from '../session/mobile-diff-review-rpc'
import { requestMobileCommitMessage } from './mobile-commit-message-ai'
import { getStageablePaths, type MobileGitStatusResult } from './mobile-git-status'
import { getMobilePrEligibilityReadiness } from './mobile-open-pr-prefill'
import { resolveMobilePrPrefill, type MobilePrPrefill } from './mobile-pr-create'
export type MobileHostedReviewCreateIntentProgress =
| 'staging'
| 'generating_commit_message'
| 'committing'
| 'publishing'
| 'pushing'
| 'force_pushing'
export type MobileHostedReviewCreateIntentOutcome =
| {
ok: true
prefill: MobilePrPrefill
status: MobileGitStatusResult | null
committed: boolean
}
| { ok: false; error: string }
type PrepareInput = {
branch: string
title: string
status: MobileGitStatusResult | null
commitMessage?: string
onProgress?: (progress: MobileHostedReviewCreateIntentProgress) => void
}
export function mobileHostedReviewCreateIntentProgressMessage(
progress: MobileHostedReviewCreateIntentProgress
): string {
switch (progress) {
case 'staging':
return 'Staging changes...'
case 'generating_commit_message':
return 'Generating commit message...'
case 'committing':
return 'Committing changes...'
case 'publishing':
return 'Publishing branch...'
case 'pushing':
return 'Pushing commits...'
case 'force_pushing':
return 'Force pushing with lease...'
}
}
async function readStatus(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string
): Promise<MobileGitStatusResult | null> {
const response = await client.sendRequest('git.status', { worktree: `id:${worktreeId}` })
if (!response.ok) {
return null
}
return readMobileGitStatusResult((response as RpcSuccess).result)
}
function branchStillMatches(inputBranch: string, status: MobileGitStatusResult | null): boolean {
return (
!status?.branch ||
status.branch === inputBranch ||
status.branch === `refs/heads/${inputBranch}`
)
}
async function sendGitMutation(
client: Pick<RpcClient, 'sendRequest'>,
method: string,
params: Record<string, unknown>,
fallback: string
): Promise<{ ok: true } | { ok: false; error: string }> {
try {
const response = await client.sendRequest(method, params)
if (!response.ok) {
return { ok: false, error: response.error?.message || fallback }
}
return { ok: true }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : fallback }
}
}
async function commitStagedChanges(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
message: string
): Promise<{ ok: true } | { ok: false; error: string }> {
try {
const response = await client.sendRequest('git.commit', {
worktree: `id:${worktreeId}`,
message
})
if (!response.ok) {
return { ok: false, error: response.error?.message || 'Commit failed' }
}
const result = (response as RpcSuccess).result as { success?: boolean; error?: string }
if (result?.success !== true) {
return { ok: false, error: result?.error || 'Commit failed' }
}
return { ok: true }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Commit failed' }
}
}
async function resolvePrefillFromStatus(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
branch: string,
title: string,
status: MobileGitStatusResult | null
): Promise<MobilePrPrefill> {
return resolveMobilePrPrefill(client, worktreeId, {
branch,
title,
...getMobilePrEligibilityReadiness(status)
})
}
async function ensureLocalChangesCommitted(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
input: PrepareInput,
currentStatus: MobileGitStatusResult | null
): Promise<
| { ok: true; status: MobileGitStatusResult | null; committed: boolean }
| { ok: false; error: string }
> {
if ((currentStatus?.entries.length ?? 0) === 0) {
return { ok: true, status: currentStatus, committed: false }
}
const stagePaths = getStageablePaths(currentStatus?.entries ?? [])
if (stagePaths.length > 0) {
input.onProgress?.('staging')
const staged = await sendGitMutation(
client,
'git.bulkStage',
{ worktree: `id:${worktreeId}`, filePaths: stagePaths },
'Failed to stage changes'
)
if (!staged.ok) {
return staged
}
currentStatus = await readStatus(client, worktreeId)
if (!branchStillMatches(input.branch, currentStatus)) {
return { ok: false, error: 'Branch changed while preparing the pull request.' }
}
}
const hasStagedChanges = currentStatus?.entries.some((entry) => entry.area === 'staged') === true
if (!hasStagedChanges) {
return { ok: false, error: 'Resolve or stage changes before creating a pull request.' }
}
let message = input.commitMessage?.trim() ?? ''
if (!message) {
input.onProgress?.('generating_commit_message')
const generated = await requestMobileCommitMessage(client, worktreeId)
if (!generated.success) {
return {
ok: false,
error: 'Could not generate a commit message. Add one in Source Control, then retry.'
}
}
message = generated.message
}
input.onProgress?.('committing')
const committed = await commitStagedChanges(client, worktreeId, message)
if (!committed.ok) {
return committed
}
currentStatus = await readStatus(client, worktreeId)
if (!branchStillMatches(input.branch, currentStatus)) {
return { ok: false, error: 'Branch changed while preparing the pull request.' }
}
return { ok: true, status: currentStatus, committed: true }
}
async function applyRemotePrerequisite(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
prefill: MobilePrPrefill,
input: PrepareInput
): Promise<{ ok: true; ran: boolean } | { ok: false; error: string }> {
switch (prefill.blockedReason) {
case 'no_upstream': {
input.onProgress?.('publishing')
const result = await sendGitMutation(
client,
'git.push',
{ worktree: `id:${worktreeId}`, publish: true },
'Failed to publish branch'
)
return result.ok ? { ok: true, ran: true } : result
}
case 'needs_push': {
input.onProgress?.('pushing')
const result = await sendGitMutation(
client,
'git.push',
{ worktree: `id:${worktreeId}` },
'Failed to push commits'
)
return result.ok ? { ok: true, ran: true } : result
}
case 'needs_sync':
if (input.status?.upstreamStatus?.behindCommitsArePatchEquivalent !== true) {
return { ok: true, ran: false }
}
input.onProgress?.('force_pushing')
const result = await sendGitMutation(
client,
'git.push',
{ worktree: `id:${worktreeId}`, forceWithLease: true },
'Failed to force push with lease'
)
return result.ok ? { ok: true, ran: true } : result
default:
return { ok: true, ran: false }
}
}
export async function prepareMobileHostedReviewCreateIntent(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
input: PrepareInput
): Promise<MobileHostedReviewCreateIntentOutcome> {
let currentStatus = (await readStatus(client, worktreeId)) ?? input.status
if (!branchStillMatches(input.branch, currentStatus)) {
return { ok: false, error: 'Branch changed while preparing the pull request.' }
}
const committed = await ensureLocalChangesCommitted(client, worktreeId, input, currentStatus)
if (!committed.ok) {
return committed
}
currentStatus = committed.status
let prefill = await resolvePrefillFromStatus(
client,
worktreeId,
input.branch,
input.title,
currentStatus
)
for (let attempts = 0; attempts < 2; attempts++) {
const remote = await applyRemotePrerequisite(client, worktreeId, prefill, {
...input,
status: currentStatus
})
if (!remote.ok) {
return remote
}
if (!remote.ran) {
break
}
currentStatus = await readStatus(client, worktreeId)
if (!branchStillMatches(input.branch, currentStatus)) {
return { ok: false, error: 'Branch changed while preparing the pull request.' }
}
prefill = await resolvePrefillFromStatus(
client,
worktreeId,
input.branch,
input.title,
currentStatus
)
}
return { ok: true, prefill, status: currentStatus, committed: committed.committed }
}
@@ -0,0 +1,266 @@
import type {
CreateHostedReviewResult,
HostedReviewCreationBlockedReason,
HostedReviewCreationEligibility,
HostedReviewCreationNextAction,
HostedReviewProvider
} from '../../../src/shared/hosted-review'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { hostedReviewCopy } from './hosted-review-copy'
import { linkMobileHostedReview } from './mobile-pr-link'
// The mobile worktree id is `${repoId}::${path}`; hosted-review RPCs expect the
// repo selector separately, matching the desktop/runtime hosted-review service.
export function mobileRepoSelectorFromWorktreeId(worktreeId: string): string {
const separatorIdx = worktreeId.indexOf('::')
const repoId = separatorIdx === -1 ? worktreeId : worktreeId.slice(0, separatorIdx)
return `id:${repoId}`
}
export type MobileHostedReviewEligibilityInput = {
branch: string
base?: string | null
hasUncommittedChanges?: boolean
hasUpstream?: boolean
ahead?: number
behind?: number
linkedGitHubPR?: number | null
linkedGitLabMR?: number | null
}
export async function fetchMobileHostedReviewEligibility(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
input: MobileHostedReviewEligibilityInput
): Promise<HostedReviewCreationEligibility | null> {
const response = await client.sendRequest('hostedReview.getCreationEligibility', {
repo: mobileRepoSelectorFromWorktreeId(worktreeId),
worktree: `id:${worktreeId}`,
branch: input.branch,
base: input.base ?? null,
...(input.hasUncommittedChanges !== undefined
? { hasUncommittedChanges: input.hasUncommittedChanges }
: {}),
...(input.hasUpstream !== undefined ? { hasUpstream: input.hasUpstream } : {}),
...(input.ahead !== undefined ? { ahead: input.ahead } : {}),
...(input.behind !== undefined ? { behind: input.behind } : {}),
linkedGitHubPR: input.linkedGitHubPR ?? null,
linkedGitLabMR: input.linkedGitLabMR ?? null
})
if (!response.ok) {
return null
}
return (response as RpcSuccess).result as HostedReviewCreationEligibility
}
export type MobileHostedReviewPrefill = {
provider: HostedReviewProvider
base: string
title: string
body: string
canCreate?: boolean
blockedReason?: HostedReviewCreationBlockedReason
nextAction?: HostedReviewCreationNextAction
}
// Resolve the mobile compose prefill from the same hosted-review eligibility
// service desktop uses. If eligibility is unavailable, return a blocked prefill
// instead of inventing a provider/base locally.
export async function resolveMobileHostedReviewPrefill(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
args: {
branch: string | undefined
title: string
hasUncommittedChanges?: boolean
hasUpstream?: boolean
ahead?: number
behind?: number
}
): Promise<MobileHostedReviewPrefill> {
const fallback: MobileHostedReviewPrefill = {
provider: 'github',
base: 'main',
title: args.title,
body: ''
}
if (!args.branch) {
return { ...fallback, canCreate: false, blockedReason: 'detached_head', nextAction: null }
}
try {
const eligibility = await fetchMobileHostedReviewEligibility(client, worktreeId, {
branch: args.branch,
hasUncommittedChanges: args.hasUncommittedChanges,
hasUpstream: args.hasUpstream,
ahead: args.ahead,
behind: args.behind
})
if (!eligibility) {
return { ...fallback, canCreate: false, blockedReason: null, nextAction: null }
}
return {
provider: eligibility.provider,
base: eligibility.defaultBaseRef || 'main',
title: eligibility.title || args.title,
body: eligibility.body || '',
canCreate: eligibility.canCreate,
blockedReason: eligibility.blockedReason,
nextAction: eligibility.nextAction
}
} catch {
return { ...fallback, canCreate: false, blockedReason: null, nextAction: null }
}
}
export function shouldPushBeforeMobileHostedReviewCreate(
prefill: Pick<MobileHostedReviewPrefill, 'blockedReason'>
): boolean {
return prefill.blockedReason === 'needs_push'
}
export type MobileHostedReviewCreateInput = {
provider: HostedReviewProvider
base: string
head?: string
title: string
body: string
draft: boolean
useTemplate?: boolean
pushBeforeCreate?: boolean
}
// Builds the hostedReview.create params, trimming title/body and dropping empty
// optional fields so the host's required-string validation passes cleanly.
export function buildMobileHostedReviewCreateParams(
worktreeId: string,
input: MobileHostedReviewCreateInput
): Record<string, unknown> {
return {
repo: mobileRepoSelectorFromWorktreeId(worktreeId),
worktree: `id:${worktreeId}`,
provider: input.provider,
base: input.base.trim(),
...(input.head && input.head.trim().length > 0 ? { head: input.head.trim() } : {}),
title: input.title.trim(),
...(input.body.trim().length > 0 ? { body: input.body.trim() } : {}),
draft: input.draft,
...(input.useTemplate !== undefined ? { useTemplate: input.useTemplate } : {})
}
}
export type MobileHostedReviewCreateOutcome =
| { ok: true; url: string; number?: number; existing?: boolean; linkError?: string }
| { ok: false; error: string }
async function pushMobileBranchBeforeCreate(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string
): Promise<{ ok: true } | { ok: false; error: string }> {
try {
const response = await client.sendRequest('git.push', { worktree: `id:${worktreeId}` })
if (!response.ok) {
return { ok: false, error: 'Push failed. Resolve the push error, then try again.' }
}
return { ok: true }
} catch {
return { ok: false, error: 'Push failed. Resolve the push error, then try again.' }
}
}
function formatMobileHostedReviewCreateError(
result: CreateHostedReviewResult,
pushed: boolean,
shortLabel: string
): string {
if (result.ok) {
return ''
}
if (!pushed) {
return result.error
}
const prefix = new RegExp(`^Create ${shortLabel} failed:\\s*`, 'i')
return `Push succeeded, but ${shortLabel} creation failed: ${result.error.replace(prefix, '')}`
}
async function finishMobileHostedReviewCreateSuccess(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
input: MobileHostedReviewCreateInput,
result: { number: number; url: string },
existing?: boolean
): Promise<MobileHostedReviewCreateOutcome> {
const linked = await linkMobileHostedReview(client, worktreeId, input.provider, result.number, {
// Why: mobile branch compare cannot infer the new hosted review's target
// base from renderer cache; persist the submitted base for the refresh.
baseRef: input.base
})
return {
ok: true,
url: result.url,
number: result.number,
...(existing ? { existing: true } : {}),
...(linked.ok ? {} : { linkError: linked.error })
}
}
export async function createMobileHostedReview(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
input: MobileHostedReviewCreateInput
): Promise<MobileHostedReviewCreateOutcome> {
let pushed = false
try {
if (input.pushBeforeCreate) {
const push = await pushMobileBranchBeforeCreate(client, worktreeId)
if (!push.ok) {
return push
}
pushed = true
}
const response = await client.sendRequest(
'hostedReview.create',
buildMobileHostedReviewCreateParams(worktreeId, input)
)
if (!response.ok) {
return { ok: false, error: response.error?.message || 'Failed to create pull request' }
}
const result = (response as RpcSuccess).result as CreateHostedReviewResult
if (result.ok) {
return finishMobileHostedReviewCreateSuccess(client, worktreeId, input, result)
}
if (result.existingReview?.url) {
const number = result.existingReview.number
if (!number) {
return {
ok: true,
url: result.existingReview.url,
existing: true
}
}
return finishMobileHostedReviewCreateSuccess(
client,
worktreeId,
input,
{ number, url: result.existingReview.url },
true
)
}
return {
ok: false,
error:
formatMobileHostedReviewCreateError(
result,
pushed,
hostedReviewCopy(input.provider).shortLabel
) || 'Failed to create pull request'
}
} catch (err) {
// Why: create review runs from an inline form; transport drops should surface
// as form errors instead of escaping as unhandled promise rejections.
return {
ok: false,
error: err instanceof Error ? err.message : 'Failed to create pull request'
}
}
}
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { readFreshGitStatus } from './mobile-open-pr-prefill'
import { getMobilePrEligibilityReadiness, readFreshGitStatus } from './mobile-open-pr-prefill'
import type { MobileGitStatusResult } from './mobile-git-status'
const fallback = { branch: 'old', entries: [] } as unknown as MobileGitStatusResult
@@ -33,3 +33,23 @@ describe('readFreshGitStatus', () => {
expect(out).toBe(fallback)
})
})
describe('getMobilePrEligibilityReadiness', () => {
it('keeps readiness fields absent when git status is unknown', () => {
expect(getMobilePrEligibilityReadiness(null)).toEqual({})
})
it('derives dirty and upstream readiness from git status', () => {
const status = {
entries: [{ path: 'a.ts' }],
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 1 }
} as unknown as MobileGitStatusResult
expect(getMobilePrEligibilityReadiness(status)).toEqual({
hasUncommittedChanges: true,
hasUpstream: true,
ahead: 2,
behind: 1
})
})
})
@@ -31,13 +31,33 @@ export async function buildOpenPrPrefill(
if (!client) {
return { provider: 'github', base: 'main', title: branchLabel, body: '' }
}
const up = status?.upstreamStatus
const gitReadiness = getMobilePrEligibilityReadiness(status)
return resolveMobilePrPrefill(client, worktreeId, {
branch: status?.branch,
title: branchLabel,
hasUncommittedChanges: (status?.entries?.length ?? 0) > 0,
hasUpstream: up?.hasUpstream === true,
ahead: up?.ahead ?? 0,
behind: up?.behind ?? 0
...gitReadiness
})
}
export function getMobilePrEligibilityReadiness(status: MobileGitStatusResult | null): {
hasUncommittedChanges?: boolean
hasUpstream?: boolean
ahead?: number
behind?: number
} {
if (!status) {
return {}
}
const up = status?.upstreamStatus
const upstreamReadiness = up
? {
hasUpstream: up.hasUpstream,
ahead: up.ahead,
behind: up.behind
}
: {}
return {
hasUncommittedChanges: (status.entries?.length ?? 0) > 0,
...upstreamReadiness
}
}
@@ -0,0 +1,258 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
import { createMobilePr } from './mobile-pr-create'
function ok(result: unknown): RpcSuccess {
return { id: 'r', ok: true, result, _meta: { runtimeId: 'rt' } }
}
function fail(message: string): RpcFailure {
return { id: 'r', ok: false, error: { code: 'x', message }, _meta: { runtimeId: 'rt' } }
}
function clientWith(responses: RpcResponse[]): Pick<RpcClient, 'sendRequest'> & {
calls: Array<{ method: string; params: unknown }>
} {
const calls: Array<{ method: string; params: unknown }> = []
return {
calls,
sendRequest: vi.fn(async (method: string, params?: unknown) => {
calls.push({ method, params })
return responses.shift() ?? fail('unexpected')
})
}
}
describe('createMobilePr', () => {
it('returns the url on success', async () => {
const client = clientWith([
ok({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' }),
ok({ worktree: { linkedPR: 42 } })
])
await expect(
createMobilePr(client, 'repo-1::/tmp/wt', {
provider: 'github',
base: 'main',
title: 'T',
body: '',
draft: false
})
).resolves.toEqual({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' })
expect(client.calls[0].method).toBe('hostedReview.create')
expect(client.calls[1]).toEqual({
method: 'worktree.set',
params: { worktree: 'id:repo-1::/tmp/wt', baseRef: 'main', linkedPR: 42 }
})
})
it('links created merge requests through the provider-specific worktree field', async () => {
const client = clientWith([
ok({ ok: true, number: 7, url: 'https://gitlab.com/o/r/-/merge_requests/7' }),
ok({ worktree: { linkedGitLabMR: 7 } })
])
await expect(
createMobilePr(client, 'repo-1::/tmp/wt', {
provider: 'gitlab',
base: 'main',
title: 'T',
body: '',
draft: false
})
).resolves.toEqual({
ok: true,
number: 7,
url: 'https://gitlab.com/o/r/-/merge_requests/7'
})
expect(client.calls[1]).toEqual({
method: 'worktree.set',
params: { worktree: 'id:repo-1::/tmp/wt', baseRef: 'main', linkedGitLabMR: 7 }
})
})
it('keeps the created url when the metadata link refresh fails', async () => {
const client = clientWith([
ok({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' }),
fail('metadata failed')
])
await expect(
createMobilePr(client, 'repo-1::/tmp/wt', {
provider: 'github',
base: 'main',
title: 'T',
body: '',
draft: false
})
).resolves.toEqual({
ok: true,
number: 42,
url: 'https://github.com/o/r/pull/42',
linkError: 'metadata failed'
})
})
it('pushes before create when eligibility requires it', async () => {
const client = clientWith([
ok({ success: true }),
ok({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' }),
ok({ worktree: { linkedPR: 42 } })
])
await expect(
createMobilePr(client, 'repo-1::/tmp/wt', {
provider: 'github',
base: 'main',
title: 'T',
body: '',
draft: false,
pushBeforeCreate: true
})
).resolves.toEqual({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' })
expect(client.calls.map((call) => call.method)).toEqual([
'git.push',
'hostedReview.create',
'worktree.set'
])
expect(client.calls[0].params).toEqual({ worktree: 'id:repo-1::/tmp/wt' })
})
it('stops before create when the required push fails', async () => {
const client = clientWith([fail('rejected')])
await expect(
createMobilePr(client, 'repo-1::/tmp/wt', {
provider: 'github',
base: 'main',
title: 'T',
body: '',
draft: false,
pushBeforeCreate: true
})
).resolves.toEqual({
ok: false,
error: 'Push failed. Resolve the push error, then try again.'
})
expect(client.calls.map((call) => call.method)).toEqual(['git.push'])
})
it('returns existing reviews as linkable success outcomes', async () => {
const client = clientWith([
ok({
ok: false,
code: 'already_exists',
error: 'Already open',
existingReview: { number: 42, url: 'https://github.com/o/r/pull/42' }
}),
ok({ worktree: { linkedPR: 42 } })
])
await expect(
createMobilePr(client, 'repo-1::/tmp/wt', {
provider: 'github',
base: 'main',
title: 'T',
body: '',
draft: false
})
).resolves.toEqual({
ok: true,
existing: true,
number: 42,
url: 'https://github.com/o/r/pull/42'
})
expect(client.calls[1]).toEqual({
method: 'worktree.set',
params: { worktree: 'id:repo-1::/tmp/wt', baseRef: 'main', linkedPR: 42 }
})
})
it('returns existing review urls even when no number is available', async () => {
const client = clientWith([
ok({
ok: false,
code: 'already_exists',
error: 'Already open',
existingReview: { url: 'https://github.com/o/r/pull/42' }
})
])
await expect(
createMobilePr(client, 'repo-1::/tmp/wt', {
provider: 'github',
base: 'main',
title: 'T',
body: '',
draft: false
})
).resolves.toEqual({
ok: true,
existing: true,
url: 'https://github.com/o/r/pull/42'
})
expect(client.calls).toHaveLength(1)
})
it('formats create failures after a successful required push like desktop', async () => {
const client = clientWith([
ok({ success: true }),
ok({ ok: false, code: 'validation', error: 'Create PR failed: bad base' })
])
await expect(
createMobilePr(client, 'repo-1::/tmp/wt', {
provider: 'github',
base: 'main',
title: 'T',
body: '',
draft: false,
pushBeforeCreate: true
})
).resolves.toEqual({
ok: false,
error: 'Push succeeded, but PR creation failed: bad base'
})
})
it('maps a host failure result to { ok:false }', async () => {
const client = clientWith([ok({ ok: false, code: 'validation', error: 'Push first' })])
await expect(
createMobilePr(client, 'repo-1::/tmp/wt', {
provider: 'github',
base: 'main',
title: 'T',
body: '',
draft: false
})
).resolves.toEqual({ ok: false, error: 'Push first' })
})
it('maps an RPC transport failure to { ok:false }', async () => {
const client = clientWith([fail('disconnected')])
const result = await createMobilePr(client, 'repo-1::/tmp/wt', {
provider: 'github',
base: 'main',
title: 'T',
body: '',
draft: false
})
expect(result).toEqual({ ok: false, error: 'disconnected' })
})
it('normalizes a thrown sendRequest into { ok:false }', async () => {
const client = {
sendRequest: vi.fn(async () => {
throw new Error('socket hung up')
})
} as unknown as Pick<RpcClient, 'sendRequest'>
await expect(
createMobilePr(client, 'repo-1::/tmp/wt', {
provider: 'github',
base: 'main',
title: 'T',
body: '',
draft: false
})
).resolves.toEqual({ ok: false, error: 'socket hung up' })
})
})
+104 -115
View File
@@ -1,11 +1,14 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
import type { HostedReviewCreationEligibility } from '../../../src/shared/hosted-review'
import { shouldOpenChecksPanelCreateComposer } from '../../../src/renderer/src/components/right-sidebar/checks-panel-review-creation'
import {
buildMobilePrCreateParams,
createMobilePr,
getMobilePrCreateBlockMessage,
mobileRepoSelectorFromWorktreeId,
resolveMobilePrPrefill
resolveMobilePrPrefill,
shouldPushBeforeMobilePrCreate
} from './mobile-pr-create'
function ok(result: unknown): RpcSuccess {
@@ -27,6 +30,22 @@ function clientWith(responses: RpcResponse[]): Pick<RpcClient, 'sendRequest'> &
}
}
function eligibility(
overrides: Partial<HostedReviewCreationEligibility> = {}
): HostedReviewCreationEligibility {
return {
provider: 'github',
review: null,
canCreate: true,
blockedReason: null,
nextAction: null,
defaultBaseRef: 'main',
title: 'Add feature',
body: '',
...overrides
}
}
describe('mobileRepoSelectorFromWorktreeId', () => {
it('extracts the repo id before the :: separator', () => {
expect(mobileRepoSelectorFromWorktreeId('repo-1::/tmp/wt')).toBe('id:repo-1')
@@ -39,10 +58,11 @@ describe('buildMobilePrCreateParams', () => {
expect(
buildMobilePrCreateParams('repo-1::/tmp/wt', {
provider: 'github',
base: 'main',
base: ' main ',
title: ' Add feature ',
body: ' ',
draft: false
draft: false,
useTemplate: true
})
).toEqual({
repo: 'id:repo-1',
@@ -50,7 +70,8 @@ describe('buildMobilePrCreateParams', () => {
provider: 'github',
base: 'main',
title: 'Add feature',
draft: false
draft: false,
useTemplate: true
})
})
@@ -67,114 +88,39 @@ describe('buildMobilePrCreateParams', () => {
})
})
describe('createMobilePr', () => {
it('returns the url on success', async () => {
const client = clientWith([
ok({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' }),
ok({ worktree: { linkedPR: 42 } })
])
await expect(
createMobilePr(client, 'repo-1::/tmp/wt', {
provider: 'github',
base: 'main',
title: 'T',
body: '',
draft: false
})
).resolves.toEqual({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' })
expect(client.calls[0].method).toBe('hostedReview.create')
expect(client.calls[1]).toEqual({
method: 'worktree.set',
params: { worktree: 'id:repo-1::/tmp/wt', baseRef: 'main', linkedPR: 42 }
describe('mobile create form gating parity', () => {
it.each([
{ reason: null, canCreate: true },
{ reason: 'dirty', canCreate: false },
{ reason: 'detached_head', canCreate: false },
{ reason: 'default_branch', canCreate: false },
{ reason: 'no_upstream', canCreate: false },
{ reason: 'needs_push', canCreate: false },
{ reason: 'needs_sync', canCreate: false },
{ reason: 'auth_required', canCreate: false },
{ reason: 'unsupported_provider', canCreate: false },
{ reason: 'existing_review', canCreate: false },
{ reason: 'fork_head_unsupported', canCreate: false }
] as const)('matches desktop composer gating for $reason', ({ reason, canCreate }) => {
const desktopEligibility = eligibility({ canCreate, blockedReason: reason })
const desktopAllowsComposer = shouldOpenChecksPanelCreateComposer({
activeReview: null,
isFolder: false,
branch: 'feature/x',
hostedReviewCreation: desktopEligibility
})
})
const mobileAllowsComposer =
getMobilePrCreateBlockMessage({
provider: desktopEligibility.provider,
base: desktopEligibility.defaultBaseRef ?? 'main',
title: desktopEligibility.title ?? 'feature/x',
body: desktopEligibility.body ?? '',
canCreate: desktopEligibility.canCreate,
blockedReason: desktopEligibility.blockedReason,
nextAction: desktopEligibility.nextAction
}) === null
it('links created merge requests through the provider-specific worktree field', async () => {
const client = clientWith([
ok({ ok: true, number: 7, url: 'https://gitlab.com/o/r/-/merge_requests/7' }),
ok({ worktree: { linkedGitLabMR: 7 } })
])
await expect(
createMobilePr(client, 'repo-1::/tmp/wt', {
provider: 'gitlab',
base: 'main',
title: 'T',
body: '',
draft: false
})
).resolves.toEqual({
ok: true,
number: 7,
url: 'https://gitlab.com/o/r/-/merge_requests/7'
})
expect(client.calls[1]).toEqual({
method: 'worktree.set',
params: { worktree: 'id:repo-1::/tmp/wt', baseRef: 'main', linkedGitLabMR: 7 }
})
})
it('keeps the created url when the metadata link refresh fails', async () => {
const client = clientWith([
ok({ ok: true, number: 42, url: 'https://github.com/o/r/pull/42' }),
fail('metadata failed')
])
await expect(
createMobilePr(client, 'repo-1::/tmp/wt', {
provider: 'github',
base: 'main',
title: 'T',
body: '',
draft: false
})
).resolves.toEqual({
ok: true,
number: 42,
url: 'https://github.com/o/r/pull/42',
linkError: 'metadata failed'
})
})
it('maps a host failure result to { ok:false }', async () => {
const client = clientWith([ok({ ok: false, code: 'needs_push', error: 'Push first' })])
await expect(
createMobilePr(client, 'repo-1::/tmp/wt', {
provider: 'github',
base: 'main',
title: 'T',
body: '',
draft: false
})
).resolves.toEqual({ ok: false, error: 'Push first' })
})
it('maps an RPC transport failure to { ok:false }', async () => {
const client = clientWith([fail('disconnected')])
const result = await createMobilePr(client, 'repo-1::/tmp/wt', {
provider: 'github',
base: 'main',
title: 'T',
body: '',
draft: false
})
expect(result).toEqual({ ok: false, error: 'disconnected' })
})
it('normalizes a thrown sendRequest into { ok:false }', async () => {
const client = {
sendRequest: vi.fn(async () => {
throw new Error('socket hung up')
})
} as unknown as Pick<RpcClient, 'sendRequest'>
await expect(
createMobilePr(client, 'repo-1::/tmp/wt', {
provider: 'github',
base: 'main',
title: 'T',
body: '',
draft: false
})
).resolves.toEqual({ ok: false, error: 'socket hung up' })
expect(mobileAllowsComposer).toBe(desktopAllowsComposer)
})
})
@@ -205,27 +151,70 @@ describe('resolveMobilePrPrefill', () => {
provider: 'gitlab',
base: 'develop',
title: 'Add feature',
body: 'Body'
body: 'Body',
canCreate: true,
blockedReason: null,
nextAction: null
})
})
it('falls back to github/main when eligibility is unavailable', async () => {
it('marks needs_push eligibility for submit-time push parity', async () => {
const client = clientWith([
ok({
provider: 'github',
canCreate: false,
review: null,
blockedReason: 'needs_push',
nextAction: 'push',
defaultBaseRef: 'main',
title: 'Add feature',
body: ''
})
])
const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)
expect(shouldPushBeforeMobilePrCreate(prefill)).toBe(true)
expect(getMobilePrCreateBlockMessage(prefill)).toBeNull()
})
it('returns a mobile block message for desktop-blocked create states', async () => {
const client = clientWith([
ok({
provider: 'github',
canCreate: false,
review: null,
blockedReason: 'dirty',
nextAction: 'commit',
defaultBaseRef: 'main'
})
])
const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)
expect(getMobilePrCreateBlockMessage(prefill)).toBe(
'Commit changes before creating a pull request.'
)
})
it('returns a blocked fallback when eligibility is unavailable', async () => {
const client = clientWith([fail('nope')])
await expect(resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)).resolves.toEqual({
provider: 'github',
base: 'main',
title: 'feature/x',
body: ''
body: '',
canCreate: false,
blockedReason: null,
nextAction: null
})
})
it('falls back without calling the RPC when there is no branch', async () => {
it('blocks without calling the RPC when there is no branch', async () => {
const client = clientWith([])
const result = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', {
...baseArgs,
branch: undefined
})
expect(result.provider).toBe('github')
expect(result.canCreate).toBe(false)
expect(result.blockedReason).toBe('detached_head')
expect(client.calls).toEqual([])
})
})
+51 -172
View File
@@ -1,179 +1,58 @@
import type {
CreateHostedReviewResult,
HostedReviewCreationEligibility,
HostedReviewProvider
} from '../../../src/shared/hosted-review'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { linkMobileHostedReview } from './mobile-pr-link'
import { hostedReviewCopy } from './hosted-review-copy'
import {
buildMobileHostedReviewCreateParams,
createMobileHostedReview,
fetchMobileHostedReviewEligibility,
mobileRepoSelectorFromWorktreeId,
resolveMobileHostedReviewPrefill,
shouldPushBeforeMobileHostedReviewCreate,
type MobileHostedReviewCreateInput,
type MobileHostedReviewCreateOutcome,
type MobileHostedReviewEligibilityInput,
type MobileHostedReviewPrefill
} from './mobile-hosted-review-service'
// The mobile worktree id is `${repoId}::${path}`; the repo selector the host
// hosted-review RPCs expect is `id:${repoId}`.
export function mobileRepoSelectorFromWorktreeId(worktreeId: string): string {
const separatorIdx = worktreeId.indexOf('::')
const repoId = separatorIdx === -1 ? worktreeId : worktreeId.slice(0, separatorIdx)
return `id:${repoId}`
export type MobilePrEligibilityInput = MobileHostedReviewEligibilityInput
export type MobilePrPrefill = MobileHostedReviewPrefill
export type MobilePrCreateInput = MobileHostedReviewCreateInput
export type MobilePrCreateOutcome = MobileHostedReviewCreateOutcome
export {
buildMobileHostedReviewCreateParams as buildMobilePrCreateParams,
createMobileHostedReview as createMobilePr,
fetchMobileHostedReviewEligibility as fetchMobilePrEligibility,
mobileRepoSelectorFromWorktreeId,
resolveMobileHostedReviewPrefill as resolveMobilePrPrefill,
shouldPushBeforeMobileHostedReviewCreate as shouldPushBeforeMobilePrCreate
}
export type MobilePrEligibilityInput = {
branch: string
base?: string | null
hasUncommittedChanges: boolean
hasUpstream: boolean
ahead: number
behind: number
linkedGitHubPR?: number | null
linkedGitLabMR?: number | null
}
export async function fetchMobilePrEligibility(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
input: MobilePrEligibilityInput
): Promise<HostedReviewCreationEligibility | null> {
const response = await client.sendRequest('hostedReview.getCreationEligibility', {
repo: mobileRepoSelectorFromWorktreeId(worktreeId),
worktree: `id:${worktreeId}`,
branch: input.branch,
base: input.base ?? null,
hasUncommittedChanges: input.hasUncommittedChanges,
hasUpstream: input.hasUpstream,
ahead: input.ahead,
behind: input.behind,
linkedGitHubPR: input.linkedGitHubPR ?? null,
linkedGitLabMR: input.linkedGitLabMR ?? null
})
if (!response.ok) {
export function getMobilePrCreateBlockMessage(prefill: MobilePrPrefill): string | null {
if (prefill.canCreate !== false || shouldPushBeforeMobileHostedReviewCreate(prefill)) {
return null
}
return (response as RpcSuccess).result as HostedReviewCreationEligibility
}
export type MobilePrPrefill = {
provider: HostedReviewProvider
base: string
title: string
body: string
}
// Fetches hosted-review eligibility and derives the PR compose prefill from it
// — so non-GitHub repos (e.g. GitLab) get the right provider/base instead of a
// hardcoded one. Falls back to a github/main default (with the branch label as
// title) when branch/eligibility is unavailable.
export async function resolveMobilePrPrefill(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
args: {
branch: string | undefined
title: string
hasUncommittedChanges: boolean
hasUpstream: boolean
ahead: number
behind: number
}
): Promise<MobilePrPrefill> {
const fallback: MobilePrPrefill = {
provider: 'github',
base: 'main',
title: args.title,
body: ''
}
if (!args.branch) {
return fallback
}
try {
const eligibility = await fetchMobilePrEligibility(client, worktreeId, {
branch: args.branch,
hasUncommittedChanges: args.hasUncommittedChanges,
hasUpstream: args.hasUpstream,
ahead: args.ahead,
behind: args.behind
})
if (!eligibility) {
return fallback
}
return {
provider: eligibility.provider,
base: eligibility.defaultBaseRef || 'main',
title: eligibility.title || args.title,
body: eligibility.body || ''
}
} catch {
return fallback
}
}
export type MobilePrCreateInput = {
provider: HostedReviewProvider
base: string
head?: string
title: string
body: string
draft: boolean
}
// Builds the hostedReview.create params, trimming title/body and dropping empty
// optional fields so the host's required-string validation passes cleanly.
export function buildMobilePrCreateParams(
worktreeId: string,
input: MobilePrCreateInput
): Record<string, unknown> {
return {
repo: mobileRepoSelectorFromWorktreeId(worktreeId),
worktree: `id:${worktreeId}`,
provider: input.provider,
base: input.base,
...(input.head && input.head.length > 0 ? { head: input.head } : {}),
title: input.title.trim(),
...(input.body.trim().length > 0 ? { body: input.body.trim() } : {}),
draft: input.draft
}
}
export type MobilePrCreateOutcome =
| { ok: true; url: string; number: number; linkError?: string }
| { ok: false; error: string }
export async function createMobilePr(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
input: MobilePrCreateInput
): Promise<MobilePrCreateOutcome> {
try {
const response = await client.sendRequest(
'hostedReview.create',
buildMobilePrCreateParams(worktreeId, input)
)
if (!response.ok) {
return { ok: false, error: response.error?.message || 'Failed to create pull request' }
}
const result = (response as RpcSuccess).result as CreateHostedReviewResult
if (result.ok) {
const linked = await linkMobileHostedReview(
client,
worktreeId,
input.provider,
result.number,
{
// Why: mobile branch compare cannot infer the new hosted review's target
// base from renderer cache; persist the submitted base for the refresh.
baseRef: input.base
}
)
return {
ok: true,
url: result.url,
number: result.number,
...(linked.ok ? {} : { linkError: linked.error })
}
}
return { ok: false, error: result.error || 'Failed to create pull request' }
} catch (err) {
// Why: create-PR runs from an inline form; transport drops should surface as
// form errors instead of escaping as unhandled promise rejections.
return {
ok: false,
error: err instanceof Error ? err.message : 'Failed to create pull request'
}
const copy = hostedReviewCopy(prefill.provider)
switch (prefill.blockedReason) {
case 'dirty':
return `Commit changes before creating a ${copy.reviewLabel}.`
case 'detached_head':
return `Check out a branch before creating a ${copy.reviewLabel}.`
case 'default_branch':
return `Switch to a feature branch before creating a ${copy.reviewLabel}.`
case 'no_upstream':
return `Publish commits before creating a ${copy.reviewLabel}.`
case 'needs_sync':
return `Sync this branch before creating a ${copy.reviewLabel}.`
case 'auth_required':
return `Authenticate before creating a ${copy.reviewLabel}.`
case 'unsupported_provider':
return `Creating ${copy.reviewLabel}s is not supported for this repo.`
case 'existing_review':
return `A ${copy.reviewLabel} already exists for this branch.`
case 'fork_head_unsupported':
return `Creating a ${copy.reviewLabel} from this fork is not supported.`
case 'needs_push':
case null:
case undefined:
return `This branch is not ready for a ${copy.reviewLabel} yet.`
}
}
@@ -0,0 +1,99 @@
import { useCallback, type MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { triggerError } from '../platform/haptics'
import type { MobileGitStatusResult } from './mobile-git-status'
import { getMobilePrCreateBlockMessage, type MobilePrPrefill } from './mobile-pr-create'
import {
mobileHostedReviewCreateIntentProgressMessage,
prepareMobileHostedReviewCreateIntent,
type MobileHostedReviewCreateIntentOutcome
} from './mobile-hosted-review-create-intent'
type RunGitWorkflow = (actionId: string, runner: () => Promise<void>) => Promise<boolean>
type Params = {
client: RpcClient | null
worktreeId: string
status: MobileGitStatusResult | null
branchLabel: string
commitMessage: string
mountedRef: MutableRefObject<boolean>
runGitWorkflow: RunGitWorkflow
setActionError: (next: string | null) => void
setCommitMessage: (next: string) => void
setShowActionSheet: (next: boolean) => void
setPrPrefill: (next: MobilePrPrefill | null) => void
setShowPrSheet: (next: boolean) => void
}
export function useMobileOpenPrSheetRunner({
client,
worktreeId,
status,
branchLabel,
commitMessage,
mountedRef,
runGitWorkflow,
setActionError,
setCommitMessage,
setShowActionSheet,
setPrPrefill,
setShowPrSheet
}: Params) {
return useCallback(
async (pushFirst: boolean) => {
setShowActionSheet(false)
const branch = status?.branch
if (!client || !branch) {
triggerError()
setActionError('Check out a branch before creating a pull request.')
return
}
const prepared: { current: MobileHostedReviewCreateIntentOutcome | null } = { current: null }
const ran = await runGitWorkflow(pushFirst ? 'push-create-pr' : 'create-pr', async () => {
prepared.current = await prepareMobileHostedReviewCreateIntent(client, worktreeId, {
branch,
title: branchLabel,
status,
commitMessage,
onProgress: (progress) =>
setActionError(mobileHostedReviewCreateIntentProgressMessage(progress))
})
if (!prepared.current.ok) {
throw new Error(prepared.current.error)
}
})
const outcome = prepared.current
if (!ran || !mountedRef.current || !outcome || !outcome.ok) {
return
}
const prefill = outcome.prefill
if (outcome.committed) {
setCommitMessage('')
}
const blockedMessage = getMobilePrCreateBlockMessage(prefill)
if (blockedMessage) {
triggerError()
setActionError(blockedMessage)
return
}
setActionError(null)
setPrPrefill(prefill)
setShowPrSheet(true)
},
[
branchLabel,
client,
commitMessage,
mountedRef,
runGitWorkflow,
setActionError,
setCommitMessage,
setPrPrefill,
setShowActionSheet,
setShowPrSheet,
status,
worktreeId
]
)
}
@@ -3,10 +3,10 @@ import { useRouter } from 'expo-router'
import type { RpcClient } from '../transport/rpc-client'
import { triggerError, triggerSuccess } from '../platform/haptics'
import type { MobilePrPrefill } from './mobile-pr-create'
import { buildOpenPrPrefill, readFreshGitStatus } from './mobile-open-pr-prefill'
import { useMobileCommitMessageGeneration } from './use-mobile-commit-message-generation'
import { useMobileSourceControlCommitRunners } from './use-mobile-source-control-commit-runners'
import { useMobileSourceControlActionSheetRunners } from './use-mobile-source-control-action-sheet-runners'
import { useMobileOpenPrSheetRunner } from './use-mobile-open-pr-sheet-runner'
import type { RuntimeGitLocalBranches } from '../../../src/shared/runtime-types'
import type { MobileGitStatusResult } from './mobile-git-status'
import type { LoadStatusOptions } from './mobile-source-control-screen-state'
@@ -184,46 +184,20 @@ export function useMobileSourceControlRunners(params: Params) {
setActionError
})
const openPrSheet = useCallback(
async (pushFirst: boolean) => {
setShowActionSheet(false)
let effectiveStatus = status
if (pushFirst) {
const pushed = await runGitWorkflow('push-create-pr', async () => {
await sendGitRequest<unknown>('git.push')
})
if (!pushed || !mountedRef.current) {
return
}
// Why: the captured `status` predates the push, so its upstream/ahead data is
// stale; read fresh git.status so the prefill reflects the just-pushed branch.
if (client) {
effectiveStatus = await readFreshGitStatus(worktreeId, status, sendGitRequest)
if (!mountedRef.current) {
return
}
}
}
const prefill = await buildOpenPrPrefill(client, worktreeId, effectiveStatus, branchLabel)
if (!mountedRef.current) {
return
}
setPrPrefill(prefill)
setShowPrSheet(true)
},
[
branchLabel,
client,
mountedRef,
runGitWorkflow,
sendGitRequest,
setPrPrefill,
setShowActionSheet,
setShowPrSheet,
status,
worktreeId
]
)
const openPrSheet = useMobileOpenPrSheetRunner({
client,
worktreeId,
status,
branchLabel,
commitMessage,
mountedRef,
runGitWorkflow,
setActionError,
setCommitMessage,
setShowActionSheet,
setPrPrefill,
setShowPrSheet
})
const openBranchPicker = useCallback(() => {
setShowActionSheet(false)