Add mobile Tasks parity (#2452)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-05-21 20:26:07 -07:00
committed by GitHub
co-authored by Orca
parent 3efc669bd4
commit b1973657ea
123 changed files with 26492 additions and 1461 deletions
+113 -8
View File
@@ -17,6 +17,7 @@ import {
Bell,
GitBranch,
GitPullRequest,
List,
SlidersHorizontal,
Layers,
ChevronDown,
@@ -90,9 +91,14 @@ type Worktree = {
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
}
type SortMode = 'smart' | 'name' | 'recent'
type RepoSummary = {
displayName: string
badgeColor?: string
}
type SortMode = 'smart' | 'name' | 'recent' | 'repo'
type _FilterMode = 'all' | 'active'
type GroupMode = 'none' | 'repo' | 'prStatus'
type GroupMode = 'none' | 'workspaceStatus' | 'repo' | 'prStatus'
type FilterState = {
activeOnly: boolean
@@ -106,11 +112,13 @@ function isErrorVerdict(v: ConnectionVerdict): boolean {
const SORT_OPTIONS: PickerOption<SortMode>[] = [
{ value: 'smart', label: 'Smart', subtitle: 'Unread and active first' },
{ value: 'name', label: 'Name', subtitle: 'Alphabetical by name' },
{ value: 'recent', label: 'Recent', subtitle: 'Most recent output first' }
{ value: 'recent', label: 'Recent', subtitle: 'Most recent output first' },
{ value: 'repo', label: 'Repo', subtitle: 'Repository, then workspace name' }
]
const GROUP_OPTIONS: PickerOption<GroupMode>[] = [
{ value: 'none', label: 'No Grouping' },
{ value: 'workspaceStatus', label: 'Status' },
{ value: 'repo', label: 'Repository' },
{ value: 'prStatus', label: 'PR Status' }
]
@@ -131,10 +139,30 @@ function isWorktreeActive(w: Worktree): boolean {
return false
}
const WORKSPACE_STATUS_LABELS: Record<ReturnType<typeof getWorktreeStatus>, string> = {
permission: 'Needs Permission',
working: 'Working',
done: 'Done',
active: 'Active',
inactive: 'Inactive'
}
const WORKSPACE_STATUS_ORDER: ReturnType<typeof getWorktreeStatus>[] = [
'permission',
'working',
'done',
'active',
'inactive'
]
function sortWorktrees(worktrees: Worktree[], mode: SortMode): Worktree[] {
return [...worktrees].sort((a, b) => {
if (mode === 'name') return (a.displayName || a.repo).localeCompare(b.displayName || b.repo)
if (mode === 'recent') return (b.lastOutputAt ?? 0) - (a.lastOutputAt ?? 0)
if (mode === 'repo') {
const repoComparison = a.repo.localeCompare(b.repo, undefined, { sensitivity: 'base' })
return repoComparison || (a.displayName || a.repo).localeCompare(b.displayName || b.repo)
}
// 'smart' — attention-first
if (a.unread !== b.unread) return a.unread ? -1 : 1
const aStatus = getWorktreeStatus(a)
@@ -237,6 +265,20 @@ function buildSections(
for (const [repo, items] of byRepo) {
sections.push({ title: repo, data: items })
}
} else if (groupMode === 'workspaceStatus') {
const byStatus = new Map<ReturnType<typeof getWorktreeStatus>, Worktree[]>()
for (const w of unpinned) {
const key = getWorktreeStatus(w)
const list = byStatus.get(key)
if (list) list.push(w)
else byStatus.set(key, [w])
}
for (const status of WORKSPACE_STATUS_ORDER) {
const items = byStatus.get(status)
if (items && items.length > 0) {
sections.push({ title: WORKSPACE_STATUS_LABELS[status], data: items })
}
}
} else if (groupMode === 'prStatus') {
const byGroup = new Map<PRGroupKey, Worktree[]>()
for (const w of unpinned) {
@@ -273,6 +315,7 @@ export default function HostScreen() {
const forceReconnectHost = useForceReconnect()
const [worktrees, setWorktrees] = useState<Worktree[]>(initialCache ?? [])
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
const [repoColorsByName, setRepoColorsByName] = useState<Map<string, string>>(new Map())
const [hostName, setHostName] = useState('')
const [error, setError] = useState('')
const [compatVerdict, setCompatVerdict] = useState<CompatVerdict>({ kind: 'ok' })
@@ -338,6 +381,7 @@ export default function HostScreen() {
setHostName('')
setError('')
setCompatVerdict({ kind: 'ok' })
setRepoColorsByName(new Map())
// Why: re-seed from the current host's cache on every hostId change.
// The useState initializer only runs on first mount, so if Expo Router
// reuses this screen with a different hostId, we must reset here.
@@ -382,6 +426,23 @@ export default function HostScreen() {
setLastKnownWorktrees(result.worktrees)
setWorktreesLoaded(true)
void requestClient
.sendRequest('repo.list')
.then((repoResponse) => {
if (clientRef.current !== requestClient || hostId !== requestHostId) return
if (!repoResponse.ok) return
const repoResult = (repoResponse as RpcSuccess).result as { repos: RepoSummary[] }
setRepoColorsByName(
new Map(
repoResult.repos.map((repo) => [
repo.displayName,
repo.badgeColor || repoColor(repo.displayName)
])
)
)
})
.catch(() => null)
// Clear optimistic sleep overrides once the server confirms the
// worktree is actually inactive (liveTerminalCount dropped to 0).
setSleptIds((prev) => {
@@ -638,10 +699,15 @@ export default function HostScreen() {
const uniqueRepos = useMemo(() => {
const repos = new Map<string, string>()
for (const w of displayWorktrees) {
if (!repos.has(w.repo)) repos.set(w.repo, repoColor(w.repo))
if (!repos.has(w.repo)) repos.set(w.repo, repoColorsByName.get(w.repo) ?? repoColor(w.repo))
}
return [...repos.entries()].map(([name, color]) => ({ name, color }))
}, [displayWorktrees])
}, [displayWorktrees, repoColorsByName])
const uniqueRepoColors = useMemo(
() => new Map(uniqueRepos.map((repo) => [repo.name, repo.color])),
[uniqueRepos]
)
const toggleCollapsed = useCallback(
(title: string) => {
@@ -752,14 +818,26 @@ export default function HostScreen() {
<Pressable style={styles.sortButton} onPress={() => setShowSortPicker(true)}>
<SlidersHorizontal size={14} color={colors.textSecondary} />
<Text style={styles.sortLabel}>
{sortMode === 'smart' ? 'Smart' : sortMode === 'name' ? 'Name' : 'Recent'}
{sortMode === 'smart'
? 'Smart'
: sortMode === 'name'
? 'Name'
: sortMode === 'repo'
? 'Repo'
: 'Recent'}
</Text>
</Pressable>
<Pressable style={styles.groupButton} onPress={() => setShowGroupPicker(true)}>
<Layers size={14} color={colors.textSecondary} />
<Text style={styles.sortLabel}>
{groupMode === 'none' ? 'Group' : groupMode === 'repo' ? 'Repo' : 'PR'}
{groupMode === 'none'
? 'Group'
: groupMode === 'workspaceStatus'
? 'Status'
: groupMode === 'repo'
? 'Repo'
: 'PR'}
</Text>
</Pressable>
@@ -776,6 +854,17 @@ export default function HostScreen() {
/>
</Pressable>
<Pressable
style={styles.searchToggle}
onPress={() => router.push(`/h/${hostId}/tasks`)}
disabled={connState !== 'connected'}
>
<List
size={16}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
<Pressable
style={styles.newButton}
onPress={() => setShowNewWorktree(true)}
@@ -873,6 +962,8 @@ export default function HostScreen() {
const isCollapsed = collapsedGroups.has(section.title)
const rawSection = rawSections.find((s) => s.title === section.title)
const count = rawSection?.data.length ?? 0
const repoSectionColor =
groupMode === 'repo' ? uniqueRepoColors.get(section.title) : null
return (
<Pressable
style={styles.sectionHeader}
@@ -886,6 +977,9 @@ export default function HostScreen() {
{section.icon === 'pin' && (
<Pin size={12} color={colors.textMuted} style={styles.sectionIcon} />
)}
{repoSectionColor ? (
<View style={[styles.sectionRepoDot, { backgroundColor: repoSectionColor }]} />
) : null}
<Text style={styles.sectionTitle}>{section.title}</Text>
<Text style={styles.sectionCount}>{count}</Text>
</Pressable>
@@ -933,7 +1027,12 @@ export default function HostScreen() {
)}
</View>
<View style={styles.worktreeMetaRow}>
<View style={[styles.repoDot, { backgroundColor: repoColor(item.repo) }]} />
<View
style={[
styles.repoDot,
{ backgroundColor: uniqueRepoColors.get(item.repo) ?? repoColor(item.repo) }
]}
/>
<Text style={styles.repoName} numberOfLines={1}>
{item.repo}
</Text>
@@ -1330,6 +1429,12 @@ const styles = StyleSheet.create({
sectionIcon: {
marginRight: spacing.xs
},
sectionRepoDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: spacing.xs
},
sectionTitle: {
fontSize: 11,
fontWeight: '600',
+50 -2
View File
@@ -18,6 +18,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {
AlertTriangle,
ArrowUp,
ChevronLeft,
ChevronRight,
@@ -32,7 +33,8 @@ import {
Plus,
RefreshCw,
Smartphone,
SquareTerminal
SquareTerminal,
X
} from 'lucide-react-native'
import type { RpcClient } from '../../../../src/transport/rpc-client'
import { loadHosts } from '../../../../src/transport/host-store'
@@ -548,18 +550,21 @@ export default function SessionScreen() {
hostId,
worktreeId,
name: worktreeName,
created
created,
warning: createdWarning
} = useLocalSearchParams<{
hostId: string
worktreeId: string
name?: string
created?: string
warning?: string
}>()
const router = useRouter()
const insets = useSafeAreaInsets()
// Why: shared client per host owned by RpcClientProvider. See
// docs/mobile-shared-client-per-host.md.
const { client, state: connState } = useHostClient(hostId)
const initialCreateWarning = typeof createdWarning === 'string' ? createdWarning.trim() : ''
const [terminals, setTerminals] = useState<Terminal[]>([])
const terminalsRef = useRef<Terminal[]>([])
const [sessionTabs, setSessionTabs] = useState<MobileSessionTab[]>([])
@@ -575,6 +580,7 @@ export default function SessionScreen() {
const [creating, setCreating] = useState(false)
const [creatingBrowser, setCreatingBrowser] = useState(false)
const [createError, setCreateError] = useState('')
const [createWarning, setCreateWarning] = useState(initialCreateWarning)
const [showCreateTabDrawer, setShowCreateTabDrawer] = useState(false)
const [showCreateBrowserModal, setShowCreateBrowserModal] = useState(false)
const [actionTarget, setActionTarget] = useState<Terminal | null>(null)
@@ -667,6 +673,10 @@ export default function SessionScreen() {
activeSessionTab?.type !== 'browser'
const [browserScreencastSupported, setBrowserScreencastSupported] = useState<boolean | null>(null)
useEffect(() => {
setCreateWarning(initialCreateWarning)
}, [initialCreateWarning])
const showToast = useCallback((message: string, durationMs = 1200) => {
setToastMessage(message)
Animated.timing(toastOpacityRef.current, {
@@ -2762,6 +2772,21 @@ export default function SessionScreen() {
)}
</SafeAreaView>
{createWarning ? (
<View style={styles.createWarningBanner}>
<AlertTriangle size={16} color={colors.statusAmber} strokeWidth={2.2} />
<Text style={styles.createWarningText}>{createWarning}</Text>
<Pressable
style={styles.createWarningDismiss}
onPress={() => setCreateWarning('')}
accessibilityLabel="Dismiss workspace creation warning"
hitSlop={8}
>
<X size={16} color={colors.textMuted} strokeWidth={2.2} />
</Pressable>
</View>
) : null}
{showLoadingState ? (
<View style={styles.emptyState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
@@ -3690,6 +3715,29 @@ const styles = StyleSheet.create({
borderRadius: radii.button,
overflow: 'hidden'
},
createWarningBanner: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
backgroundColor: colors.bgPanel,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm
},
createWarningText: {
flex: 1,
color: colors.textPrimary,
fontSize: 12,
lineHeight: 16
},
createWarningDismiss: {
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
marginTop: -4
},
emptyState: {
flex: 1,
alignItems: 'center',
File diff suppressed because it is too large Load Diff
+1
View File
@@ -11,6 +11,7 @@ export default function HostGroupLayout() {
>
<Stack.Screen name="[hostId]/index" options={{ title: 'Host' }} />
<Stack.Screen name="[hostId]/accounts" options={{ title: 'Accounts' }} />
<Stack.Screen name="[hostId]/tasks" options={{ title: 'Tasks' }} />
<Stack.Screen name="[hostId]/session/[worktreeId]" options={{ title: 'Terminal' }} />
<Stack.Screen
name="[hostId]/source-control/[worktreeId]"
+249 -59
View File
@@ -6,15 +6,13 @@ import {
Monitor,
QrCode,
Settings,
Bot,
Clock,
GitPullRequest,
ChevronRight,
Terminal,
Plus,
RefreshCw,
PowerOff,
Edit3
Edit3,
ListTodo
} from 'lucide-react-native'
import { ClaudeIcon, OpenAIIcon } from '../src/components/AgentIcons'
import {
@@ -38,12 +36,18 @@ import type { ConnectionState, HostProfile } from '../src/transport/types'
import { triggerMediumImpact } from '../src/platform/haptics'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { StatusDot } from '../src/components/StatusDot'
import { TaskProviderLogo } from '../src/components/TaskProviderLogo'
import { TextInputModal } from '../src/components/TextInputModal'
import { ActionSheetModal, type ActionSheetAction } from '../src/components/ActionSheetModal'
import { ConfirmModal } from '../src/components/ConfirmModal'
import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache'
import { loadHomeSnapshot, saveHomeSnapshot } from '../src/cache/home-snapshot-cache'
import { colors, spacing, radii } from '../src/theme/mobile-theme'
import {
filterAvailableTaskProviders,
normalizeVisibleTaskProviders,
type TaskProvider
} from '../src/tasks/mobile-task-providers'
function endpointLabel(endpoint: string): string {
try {
@@ -77,6 +81,24 @@ type HostWorktreeInfo = {
lastActiveWorktree: WorktreeSummary | null
}
type HomeTaskSettings = {
visibleTaskProviders?: unknown
}
type HomePreflightStatus = {
glab?: { installed?: boolean }
}
type HomeLinearStatus = {
connected?: boolean
}
const TASK_PROVIDER_LABELS: Record<TaskProvider, string> = {
github: 'GitHub',
gitlab: 'GitLab',
linear: 'Linear'
}
function formatDuration(ms: number): string {
const totalMinutes = Math.floor(ms / 60_000)
const totalHours = Math.floor(totalMinutes / 60)
@@ -196,6 +218,44 @@ function fetchAccountsSnapshot(
.catch(() => {})
}
function fetchTaskProviders(
client: RpcClient,
hostId: string,
setProviders: (
updater: (prev: Record<string, TaskProvider[]>) => Record<string, TaskProvider[]>
) => void,
disposed: () => boolean
) {
Promise.all([
client.sendRequest('settings.get'),
client.sendRequest('preflight.check'),
client.sendRequest('linear.status')
])
.then(([settingsResponse, preflightResponse, linearResponse]) => {
if (disposed()) return
const settings = settingsResponse.ok
? (((settingsResponse.result as { settings?: HomeTaskSettings }).settings ??
{}) as HomeTaskSettings)
: {}
const preflight = preflightResponse.ok
? (preflightResponse.result as HomePreflightStatus)
: null
const linear = linearResponse.ok ? (linearResponse.result as HomeLinearStatus) : null
const providers = filterAvailableTaskProviders(
normalizeVisibleTaskProviders(settings.visibleTaskProviders),
{
gitlabInstalled: preflight?.glab?.installed === true,
linearConnected: linear?.connected === true
}
)
setProviders((prev) => ({ ...prev, [hostId]: providers }))
})
.catch(() => {
if (disposed()) return
setProviders((prev) => (prev[hostId] ? prev : { ...prev, [hostId]: ['github'] }))
})
}
// Why: repo names get a stable color derived from hashing, matching the
// host detail page's colored dots for visual consistency.
const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4']
@@ -220,6 +280,7 @@ export default function HomeScreen() {
const [stats, setStats] = useState<StatsSummary | null>(null)
const [worktreeInfo, setWorktreeInfo] = useState<Record<string, HostWorktreeInfo>>({})
const [accountsByHost, setAccountsByHost] = useState<Record<string, AccountsSnapshot>>({})
const [taskProvidersByHost, setTaskProvidersByHost] = useState<Record<string, TaskProvider[]>>({})
const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>(
null
)
@@ -305,6 +366,7 @@ export default function HomeScreen() {
fetchStats(entry.client, setStats, () => stale)
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale)
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale)
}
}
return () => {
@@ -415,6 +477,7 @@ export default function HomeScreen() {
statsFetched = true
fetchStats(entry.client, setStats, () => false)
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false)
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false)
}
} else {
if (unsubNotif) {
@@ -499,6 +562,75 @@ export default function HomeScreen() {
return items
}, [sortedHosts, hostStates, accountsByHost])
const primaryConnectedHost = useMemo(
() => sortedHosts.find((host) => hostStates[host.id] === 'connected') ?? null,
[sortedHosts, hostStates]
)
const primaryTaskProviders = primaryConnectedHost
? (taskProvidersByHost[primaryConnectedHost.id] ?? ['github'])
: []
const openTasks = useCallback(
(provider?: TaskProvider) => {
if (!primaryConnectedHost) return
const suffix = provider ? `?taskSource=${provider}` : ''
router.push(`/h/${primaryConnectedHost.id}/tasks${suffix}`)
},
[primaryConnectedHost, router]
)
const renderTaskHomeCard = () => (
<Pressable
disabled={!primaryConnectedHost}
style={({ pressed }) => [
styles.taskHomeCard,
!primaryConnectedHost && styles.quickActionDisabled,
pressed && styles.hostCardPressed
]}
onPress={() => {
openTasks()
}}
>
<View style={styles.taskHomeIcon}>
<ListTodo size={18} color={colors.textSecondary} />
</View>
<View style={styles.taskHomeMain}>
<Text style={styles.taskHomeTitle}>Tasks</Text>
<Text style={styles.taskHomeSubtitle} numberOfLines={1}>
{primaryTaskProviders.length > 0
? primaryTaskProviders.map((provider) => TASK_PROVIDER_LABELS[provider]).join(' · ')
: 'No task sources connected'}
</Text>
</View>
<View style={styles.taskHomeTrailing}>
<View
style={styles.taskHomeProviderRow}
accessibilityLabel={primaryTaskProviders
.map((provider) => TASK_PROVIDER_LABELS[provider])
.join(', ')}
>
{primaryTaskProviders.map((provider) => (
<Pressable
key={provider}
accessibilityRole="button"
accessibilityLabel={`Open ${TASK_PROVIDER_LABELS[provider]} tasks`}
hitSlop={8}
style={({ pressed }) => [
styles.taskHomeProviderButton,
pressed && styles.taskHomeProviderButtonPressed
]}
onPress={(event) => {
event.stopPropagation()
openTasks(provider)
}}
>
<TaskProviderLogo provider={provider} size={22} color={colors.textSecondary} />
</Pressable>
))}
</View>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
)
async function handleRename(newName: string) {
if (!renameTarget) return
try {
@@ -590,25 +722,16 @@ export default function HomeScreen() {
{stats && (
<View style={styles.statsRow}>
<View style={styles.statCard}>
<View style={styles.statIcon}>
<Bot size={14} color={colors.textMuted} />
</View>
<Text style={styles.statValue}>
{stats.totalAgentsSpawned.toLocaleString()}
</Text>
<Text style={styles.statLabel}>Agents spawned</Text>
</View>
<View style={styles.statCard}>
<View style={styles.statIcon}>
<Clock size={14} color={colors.textMuted} />
</View>
<Text style={styles.statValue}>{formatDuration(stats.totalAgentTimeMs)}</Text>
<Text style={styles.statLabel}>Agent time</Text>
</View>
<View style={styles.statCard}>
<View style={styles.statIcon}>
<GitPullRequest size={14} color={colors.textMuted} />
</View>
<Text style={styles.statValue}>{stats.totalPRsCreated.toLocaleString()}</Text>
<Text style={styles.statLabel}>PRs created</Text>
</View>
@@ -676,7 +799,7 @@ export default function HomeScreen() {
{/* ─── Resume card ─── */}
{resumeWorktree ? (
<>
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Resume</Text>
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Resume</Text>
<Pressable
style={({ pressed }) => [styles.resumeCard, pressed && styles.hostCardPressed]}
onPress={() =>
@@ -708,8 +831,47 @@ export default function HomeScreen() {
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Tasks</Text>
{renderTaskHomeCard()}
</>
) : null}
) : (
<>
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Tasks</Text>
{renderTaskHomeCard()}
</>
)}
{/* ─── Quick actions ─── */}
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Quick Actions</Text>
<View style={styles.quickActions}>
<Pressable
style={({ pressed }) => [styles.quickAction, pressed && styles.hostCardPressed]}
onPress={() => router.push('/pair-scan')}
>
<View style={styles.quickActionIcon}>
<QrCode size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>Pair Desktop</Text>
</Pressable>
<Pressable
disabled={!primaryConnectedHost}
style={({ pressed }) => [
styles.quickAction,
!primaryConnectedHost && styles.quickActionDisabled,
pressed && styles.hostCardPressed
]}
onPress={() => {
if (primaryConnectedHost) {
router.push(`/h/${primaryConnectedHost.id}?action=newWorktree`)
}
}}
>
<View style={styles.quickActionIcon}>
<Plus size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>New Workspace</Text>
</Pressable>
</View>
{/* ─── Account usage ─── */}
{accountsHosts.length > 0 ? (
@@ -789,34 +951,6 @@ export default function HomeScreen() {
})}
</>
) : null}
{/* ─── Quick actions ─── */}
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Quick Actions</Text>
<View style={styles.quickActions}>
<Pressable
style={({ pressed }) => [styles.quickAction, pressed && styles.hostCardPressed]}
onPress={() => router.push('/pair-scan')}
>
<View style={styles.quickActionIcon}>
<QrCode size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>Pair Desktop</Text>
</Pressable>
<Pressable
style={({ pressed }) => [styles.quickAction, pressed && styles.hostCardPressed]}
onPress={() => {
const connectedHost = sortedHosts.find((h) => hostStates[h.id] === 'connected')
if (connectedHost) {
router.push(`/h/${connectedHost.id}?action=newWorktree`)
}
}}
>
<View style={styles.quickActionIcon}>
<Plus size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>New Worktree</Text>
</Pressable>
</View>
</View>
}
/>
@@ -965,12 +1099,12 @@ const styles = StyleSheet.create({
/* ─── Hero / greeting ─── */
hero: {
paddingTop: spacing.md,
paddingBottom: spacing.lg
paddingTop: spacing.xs,
paddingBottom: spacing.md
},
heroTitle: {
color: colors.textPrimary,
fontSize: 26,
fontSize: 24,
fontWeight: '800',
letterSpacing: -0.3
},
@@ -979,7 +1113,7 @@ const styles = StyleSheet.create({
statsRow: {
flexDirection: 'row',
gap: 10,
marginBottom: spacing.xl
marginBottom: spacing.lg
},
statCard: {
flex: 1,
@@ -987,18 +1121,9 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: 10,
paddingVertical: 10,
paddingVertical: 8,
paddingHorizontal: spacing.md
},
statIcon: {
width: 26,
height: 26,
borderRadius: 6,
backgroundColor: 'rgba(255,255,255,0.04)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 6
},
statValue: {
color: colors.textPrimary,
fontSize: 18,
@@ -1022,6 +1147,9 @@ const styles = StyleSheet.create({
marginBottom: spacing.sm,
paddingHorizontal: spacing.xs
},
sectionHeadingTightTop: {
marginTop: spacing.lg
},
/* ─── List ─── */
list: {
@@ -1038,7 +1166,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
paddingLeft: spacing.md,
paddingRight: spacing.md,
paddingVertical: 14,
paddingVertical: 12,
borderRadius: radii.card,
backgroundColor: colors.bgPanel,
borderWidth: 1,
@@ -1101,7 +1229,7 @@ const styles = StyleSheet.create({
borderRadius: radii.card,
paddingLeft: spacing.md,
paddingRight: spacing.md,
paddingVertical: 14
paddingVertical: 12
},
resumeIcon: {
width: 46,
@@ -1138,6 +1266,65 @@ const styles = StyleSheet.create({
flex: 1
},
/* ─── Tasks card ─── */
taskHomeCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
minHeight: 72,
paddingLeft: spacing.md,
paddingRight: spacing.md,
paddingVertical: 12
},
taskHomeIcon: {
width: 46,
height: 46,
borderRadius: 13,
backgroundColor: colors.bgRaised,
alignItems: 'center',
justifyContent: 'center',
marginRight: 14
},
taskHomeMain: {
flex: 1,
minWidth: 0
},
taskHomeTitle: {
fontSize: 13,
fontWeight: '600',
color: colors.textPrimary
},
taskHomeSubtitle: {
fontSize: 12,
color: colors.textSecondary,
marginTop: 3
},
taskHomeTrailing: {
flexDirection: 'row',
alignItems: 'center',
flexShrink: 0,
marginLeft: spacing.sm
},
taskHomeProviderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 2
},
taskHomeProviderButton: {
width: 34,
height: 34,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
},
taskHomeProviderButtonPressed: {
backgroundColor: colors.bgRaised
},
/* ─── Account usage ─── */
accountsCard: {
backgroundColor: colors.bgPanel,
@@ -1202,6 +1389,9 @@ const styles = StyleSheet.create({
alignItems: 'center',
gap: 10
},
quickActionDisabled: {
opacity: 0.45
},
quickActionIcon: {
width: 28,
height: 28,
+198 -7
View File
@@ -5,8 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Orca Mobile Homepage Redesign</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
:root {
@@ -24,7 +22,7 @@
}
body {
font-family: 'Inter', -apple-system, sans-serif;
font-family: Geist, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg-base);
color: var(--text-primary);
max-width: 430px;
@@ -67,6 +65,23 @@
transition: background 0.15s;
}
.icon-btn:hover { background: var(--bg-raised); }
.icon-btn.has-badge {
position: relative;
}
.nav-badge {
position: absolute;
top: 6px;
right: 5px;
min-width: 14px;
height: 14px;
padding: 0 3px;
border-radius: 999px;
background: var(--text-primary);
color: var(--bg-base);
font-size: 9px;
line-height: 14px;
font-weight: 800;
}
/* ─── Greeting ─── */
.greeting {
@@ -207,6 +222,126 @@
color: var(--text-muted);
flex-shrink: 0;
}
.host-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.host-task-chip {
min-height: 28px;
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid var(--border-subtle);
border-radius: 999px;
padding: 5px 8px;
background: rgba(255,255,255, 0.03);
color: var(--text-secondary);
font-size: 11px;
font-weight: 700;
}
.host-task-chip svg {
width: 12px;
height: 12px;
}
/* ─── Task inbox placement candidate ─── */
.task-inbox {
margin: 0 16px;
border: 1px solid var(--border-subtle);
border-radius: 14px;
background: var(--bg-panel);
overflow: hidden;
cursor: pointer;
transition: background 0.15s;
}
.task-inbox:hover { background: var(--bg-raised); }
.task-inbox-main {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
}
.task-inbox-icon {
width: 42px;
height: 42px;
border-radius: 12px;
background: rgba(255,255,255, 0.04);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
flex: none;
}
.task-inbox-copy {
flex: 1;
min-width: 0;
}
.task-inbox-title {
font-size: 14px;
line-height: 18px;
font-weight: 700;
color: var(--text-primary);
}
.task-inbox-meta {
margin-top: 3px;
display: flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
font-size: 12px;
min-width: 0;
}
.provider-dots {
display: inline-flex;
align-items: center;
gap: 3px;
flex: none;
}
.provider-dot {
width: 6px;
height: 6px;
border-radius: 999px;
}
.task-inbox-count {
color: var(--text-primary);
font-size: 20px;
line-height: 22px;
font-weight: 800;
letter-spacing: -0.3px;
flex: none;
}
.task-inbox-breakdown {
display: grid;
grid-template-columns: repeat(3, 1fr);
border-top: 1px solid var(--border-subtle);
}
.task-inbox-stat {
padding: 10px 12px;
}
.task-inbox-stat + .task-inbox-stat {
border-left: 1px solid var(--border-subtle);
}
.task-inbox-stat-value {
color: var(--text-primary);
font-size: 13px;
font-weight: 800;
}
.task-inbox-stat-label {
margin-top: 2px;
color: var(--text-muted);
font-size: 10px;
font-weight: 600;
white-space: nowrap;
}
.placement-note {
margin: 8px 20px 0;
color: var(--text-muted);
font-size: 11px;
line-height: 15px;
}
/* ─── Quick actions ─── */
.quick-actions {
@@ -501,7 +636,7 @@
<div class="compare-bar">
<button class="active" onclick="toggle('current')">Current</button>
<button onclick="toggle('proposed')">Proposed</button>
<button onclick="toggle('proposed')">Task Entry</button>
<button onclick="toggle('empty')">Empty State</button>
</div>
@@ -581,6 +716,10 @@
<span class="brand-name">Orca</span>
</div>
<div class="top-actions">
<button class="icon-btn has-badge" title="Tasks">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M6 9v12"/></svg>
<span class="nav-badge">7</span>
</button>
<button class="icon-btn" title="Settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
@@ -617,6 +756,45 @@
</div>
</div>
<div class="section-heading">Tasks</div>
<div class="task-inbox">
<div class="task-inbox-main">
<div class="task-inbox-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M6 9v12"/></svg>
</div>
<div class="task-inbox-copy">
<div class="task-inbox-title">Task inbox</div>
<div class="task-inbox-meta">
<span class="provider-dots">
<span class="provider-dot" style="background:#f97316;"></span>
<span class="provider-dot" style="background:#8b5cf6;"></span>
<span class="provider-dot" style="background:#22c55e;"></span>
</span>
GitHub, GitLab, Linear
</div>
</div>
<div class="task-inbox-count">7</div>
<div class="host-chevron">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
<div class="task-inbox-breakdown">
<div class="task-inbox-stat">
<div class="task-inbox-stat-value">3</div>
<div class="task-inbox-stat-label">Assigned</div>
</div>
<div class="task-inbox-stat">
<div class="task-inbox-stat-value">2</div>
<div class="task-inbox-stat-label">Review</div>
</div>
<div class="task-inbox-stat">
<div class="task-inbox-stat-value">2</div>
<div class="task-inbox-stat-label">Drafts</div>
</div>
</div>
</div>
<div class="placement-note">Best candidate: puts issues/PRs at the same hierarchy as desktops without overloading Quick Actions.</div>
<!-- Host cards with richer metadata -->
<div class="section-heading">Desktops</div>
<div class="host-list">
@@ -633,8 +811,14 @@
<span class="host-meta-item" style="color: var(--status-green);">3 active</span>
</div>
</div>
<div class="host-chevron">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
<div class="host-actions">
<div class="host-task-chip">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M6 9v12"/></svg>
7
</div>
<div class="host-chevron">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
@@ -697,6 +881,13 @@
<div class="section-heading">Recent Activity</div>
<div class="activity-list">
<div class="activity-card">
<div class="activity-item">
<div class="activity-dot pr"></div>
<div class="activity-main">
<div class="activity-text">Assigned: Investigate remote task loading</div>
<div class="activity-time">GitHub · Host 1 · 8 min ago</div>
</div>
</div>
<div class="activity-item">
<div class="activity-dot working"></div>
<div class="activity-main">
@@ -798,7 +989,7 @@ function toggle(version) {
document.querySelectorAll('.compare-bar button').forEach(btn => {
btn.classList.toggle('active',
(version === 'current' && btn.textContent === 'Current') ||
(version === 'proposed' && btn.textContent === 'Proposed') ||
(version === 'proposed' && btn.textContent === 'Task Entry') ||
(version === 'empty' && btn.textContent === 'Empty State')
);
});
File diff suppressed because it is too large Load Diff
-26
View File
@@ -1418,56 +1418,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.47.0':
resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.47.0':
resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.47.0':
resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.47.0':
resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.47.0':
resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.47.0':
resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.47.0':
resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.47.0':
resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==}
@@ -1540,56 +1532,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.63.0':
resolution: {integrity: sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.63.0':
resolution: {integrity: sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.63.0':
resolution: {integrity: sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.63.0':
resolution: {integrity: sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.63.0':
resolution: {integrity: sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.63.0':
resolution: {integrity: sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.63.0':
resolution: {integrity: sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.63.0':
resolution: {integrity: sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==}
@@ -2051,42 +2035,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==}
@@ -4263,28 +4241,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==}
+16 -3
View File
@@ -38,9 +38,16 @@ type Props = {
onClose: () => void
children: ReactNode
dragContentToDismiss?: boolean
zIndex?: number
}
export function BottomDrawer({ visible, onClose, children, dragContentToDismiss = false }: Props) {
export function BottomDrawer({
visible,
onClose,
children,
dragContentToDismiss = true,
zIndex
}: Props) {
const [mounted, setMounted] = useState(visible)
useEffect(() => {
@@ -59,6 +66,7 @@ export function BottomDrawer({ visible, onClose, children, dragContentToDismiss
onClose={onClose}
onHidden={() => setMounted(false)}
dragContentToDismiss={dragContentToDismiss}
zIndex={zIndex}
>
{children}
</MountedBottomDrawer>
@@ -74,7 +82,8 @@ function MountedBottomDrawer({
onClose,
onHidden,
children,
dragContentToDismiss = false
dragContentToDismiss = true,
zIndex = 1000
}: MountedBottomDrawerProps) {
const translateY = useSharedValue(0)
const progress = useSharedValue(0)
@@ -239,7 +248,11 @@ function MountedBottomDrawer({
)
return (
<Animated.View style={[styles.overlay, pointerStyle]} accessibilityViewIsModal aria-modal>
<Animated.View
style={[styles.overlay, { zIndex, elevation: zIndex }, pointerStyle]}
accessibilityViewIsModal
aria-modal
>
<GestureHandlerRootView style={styles.root}>
<Animated.View style={[styles.backdrop, backdropStyle]}>
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
+89
View File
@@ -0,0 +1,89 @@
import { Image, StyleSheet, Text, View } from 'react-native'
import { Terminal } from 'lucide-react-native'
import Svg, { G, Path } from 'react-native-svg'
import { colors } from '../theme/mobile-theme'
import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
import { ClaudeIcon, OpenAIIcon } from './AgentIcons'
// Why: agent branding should match the desktop/new-worktree picker everywhere
// mobile lets users choose the agent that will own a workspace.
function PiIcon({ size = 16 }: { size?: number }) {
return (
<Svg width={size} height={size} viewBox="0 0 800 800">
<Path
fill={colors.textPrimary}
fillRule="evenodd"
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
/>
<Path fill={colors.textPrimary} d="M517.36 400 H634.72 V634.72 H517.36 Z" />
</Svg>
)
}
function AiderIcon({ size = 16 }: { size?: number }) {
return (
<Svg width={size} height={size} viewBox="0 0 436 436">
<G transform="translate(0,436) scale(0.1,-0.1)" fill={colors.textPrimary} stroke="none">
<Path d="M0 2180 l0 -2180 2180 0 2180 0 0 2180 0 2180 -2180 0 -2180 0 0 -2180z m2705 1818 c20 -20 28 -121 30 -398 l2 -305 216 -5 c118 -3 218 -8 222 -12 3 -3 10 -46 15 -95 5 -48 16 -126 25 -172 17 -86 17 -81 -17 -233 -14 -67 -13 -365 2 -438 21 -100 22 -159 5 -247 -24 -122 -24 -363 1 -458 23 -88 23 -213 1 -330 -9 -49 -17 -109 -17 -132 l0 -43 203 0 c111 0 208 -4 216 -9 10 -6 18 -51 27 -148 8 -76 16 -152 20 -168 7 -39 -23 -361 -37 -387 -10 -18 -21 -19 -214 -16 -135 2 -208 7 -215 14 -22 22 -33 301 -21 501 6 102 8 189 5 194 -8 13 -417 12 -431 -2 -12 -12 -8 -146 8 -261 8 -55 8 -95 1 -140 -6 -35 -14 -99 -17 -143 -9 -123 -14 -141 -41 -154 -18 -8 -217 -11 -679 -11 l-653 0 -11 33 c-31 97 -43 336 -27 533 5 56 6 113 2 128 l-6 26 -194 0 c-211 0 -252 4 -261 28 -12 33 -17 392 -6 522 15 186 -2 174 260 180 115 3 213 8 217 12 4 4 1 52 -5 105 -7 54 -17 130 -22 168 -7 56 -5 91 11 171 10 55 22 130 26 166 4 36 10 72 15 79 7 12 128 15 665 19 l658 5 8 30 c5 18 4 72 -3 130 -12 115 -7 346 11 454 10 61 10 75 -1 82 -8 5 -300 9 -650 9 l-636 0 -27 25 c-18 16 -26 34 -26 57 0 18 -5 87 -10 153 -10 128 5 449 22 472 5 7 26 13 46 15 78 6 1281 3 1287 -4z" />
<Path d="M1360 1833 c0 -5 -1 -164 -3 -356 l-2 -347 625 -1 c704 -1 708 -1 722 7 5 4 7 20 4 38 -29 141 -32 491 -6 595 9 38 8 45 -7 57 -15 11 -139 13 -675 14 -362 0 -658 -3 -658 -7z" />
</G>
</Svg>
)
}
function FaviconIcon({ domain, size = 16 }: { domain: string; size?: number }) {
return (
<Image
source={{ uri: `https://www.google.com/s2/favicons?domain=${domain}&sz=64` }}
style={{ width: size, height: size, borderRadius: 2 }}
/>
)
}
function AgentLetterIcon({ letter, size = 16 }: { letter: string; size?: number }) {
return (
<View
style={[
styles.letterIcon,
{
width: size,
height: size,
borderRadius: size * 0.22,
backgroundColor: colors.textMuted + '33'
}
]}
>
<Text style={[styles.letterIconText, { fontSize: size * 0.55, color: colors.textPrimary }]}>
{letter}
</Text>
</View>
)
}
export function MobileAgentIcon({ agentId, size = 16 }: { agentId: string; size?: number }) {
if (agentId === 'claude') return <ClaudeIcon size={size} />
if (agentId === 'codex') return <OpenAIIcon size={size} />
if (agentId === 'pi') return <PiIcon size={size} />
if (agentId === 'aider') return <AiderIcon size={size} />
if (agentId === '__blank__' || agentId === 'blank') {
return <Terminal size={size} color={colors.textMuted} />
}
const agent = MOBILE_AGENT_CATALOG.find((entry) => entry.id === agentId)
if (agent?.faviconDomain) {
return <FaviconIcon domain={agent.faviconDomain} size={size} />
}
const label = agent?.label ?? agentId
return <AgentLetterIcon letter={label.charAt(0).toUpperCase()} size={size} />
}
const styles = StyleSheet.create({
letterIcon: {
alignItems: 'center',
justifyContent: 'center'
},
letterIconText: {
fontWeight: '700'
}
})
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { parseMobileMarkdown } from './mobile-markdown-parser'
describe('parseMobileMarkdown', () => {
it('parses GFM tables into table blocks', () => {
expect(parseMobileMarkdown('| Name | State |\n| --- | --- |\n| Orca | Open |')).toEqual([
{
type: 'table',
headers: ['Name', 'State'],
rows: [['Orca', 'Open']]
}
])
})
it('parses standalone HTTPS images without folding them into paragraphs', () => {
expect(parseMobileMarkdown('![Screenshot](https://example.com/screen.png)')).toEqual([
{
type: 'image',
alt: 'Screenshot',
url: 'https://example.com/screen.png'
}
])
})
})
+354
View File
@@ -0,0 +1,354 @@
import { Fragment, memo, useMemo, type ReactNode } from 'react'
import { Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { parseMobileMarkdown } from './mobile-markdown-parser'
type Props = {
content?: string
fallback?: string
}
const MAX_TABLE_ROWS = 40
const MAX_TABLE_COLUMNS = 8
function openMarkdownUrl(url: string): void {
const trimmed = url.trim()
if (/^(https?:|mailto:)/i.test(trimmed)) {
void Linking.openURL(trimmed)
}
}
function renderInline(text: string): ReactNode[] {
const parts: ReactNode[] = []
const pattern =
/(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = pattern.exec(text))) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index))
}
const token = match[0]
const key = `${match.index}:${token}`
const image = token.match(/^!\[([^\]]*)\]\(([^)]+)\)$/)
const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
if (image) {
parts.push(
<Text key={key} style={styles.link} onPress={() => openMarkdownUrl(image[2]!)}>
{image[1] || 'image'}
</Text>
)
} else if (link) {
parts.push(
<Text key={key} style={styles.link} onPress={() => openMarkdownUrl(link[2]!)}>
{link[1]}
</Text>
)
} else if (/^https?:\/\//i.test(token)) {
parts.push(
<Text key={key} style={styles.link} onPress={() => openMarkdownUrl(token)}>
{token}
</Text>
)
} else if (token.startsWith('`')) {
parts.push(
<Text key={key} style={styles.inlineCode}>
{token.slice(1, -1)}
</Text>
)
} else if (token.startsWith('~~')) {
parts.push(
<Text key={key} style={styles.strike}>
{token.slice(2, -2)}
</Text>
)
} else if (token.startsWith('**') || token.startsWith('__')) {
parts.push(
<Text key={key} style={styles.bold}>
{token.slice(2, -2)}
</Text>
)
} else {
parts.push(
<Text key={key} style={styles.italic}>
{token.slice(1, -1)}
</Text>
)
}
lastIndex = pattern.lastIndex
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex))
}
return parts
}
function MobileMarkdownInner({ content, fallback = '' }: Props) {
const text = content?.trim() ?? ''
const blocks = useMemo(() => parseMobileMarkdown(text), [text])
if (!text) {
return fallback ? <Text style={styles.paragraph}>{fallback}</Text> : null
}
return (
<View style={styles.root}>
{blocks.map((block, index) => {
if (block.type === 'heading') {
return (
<Text
key={index}
style={[styles.heading, block.level <= 2 ? styles.headingLarge : null]}
>
{renderInline(block.text)}
</Text>
)
}
if (block.type === 'quote') {
return (
<View key={index} style={styles.quote}>
<Text style={styles.quoteText}>{renderInline(block.text)}</Text>
</View>
)
}
if (block.type === 'code') {
return (
<View key={index} style={styles.codeBlock}>
{block.language ? <Text style={styles.codeLanguage}>{block.language}</Text> : null}
<Text style={styles.codeText}>{block.text}</Text>
</View>
)
}
if (block.type === 'image') {
return (
<Pressable
key={index}
style={styles.imageFrame}
onPress={() => openMarkdownUrl(block.url)}
>
<Text style={styles.link}>{block.alt || 'Open image'}</Text>
<Text style={styles.imageCaption} numberOfLines={1}>
{block.url}
</Text>
</Pressable>
)
}
if (block.type === 'table') {
const visibleHeaders = block.headers.slice(0, MAX_TABLE_COLUMNS)
const visibleRows = block.rows.slice(0, MAX_TABLE_ROWS)
const hiddenRows = Math.max(0, block.rows.length - visibleRows.length)
const hiddenColumns = Math.max(0, block.headers.length - visibleHeaders.length)
return (
<ScrollView key={index} horizontal showsHorizontalScrollIndicator={false}>
<View style={styles.table}>
<View style={styles.tableRow}>
{visibleHeaders.map((header, cellIndex) => (
<Text key={cellIndex} style={[styles.tableCell, styles.tableHeader]}>
{renderInline(header)}
</Text>
))}
</View>
{visibleRows.map((row, rowIndex) => (
<View key={rowIndex} style={styles.tableRow}>
{visibleHeaders.map((_, cellIndex) => (
<Text key={cellIndex} style={styles.tableCell}>
{renderInline(row[cellIndex] ?? '')}
</Text>
))}
</View>
))}
{hiddenRows > 0 || hiddenColumns > 0 ? (
<Text style={styles.tableTruncated}>
{hiddenRows > 0 ? `${hiddenRows} more rows` : ''}
{hiddenRows > 0 && hiddenColumns > 0 ? ' · ' : ''}
{hiddenColumns > 0 ? `${hiddenColumns} more columns` : ''}
</Text>
) : null}
</View>
</ScrollView>
)
}
if (block.type === 'list') {
return (
<View key={index} style={styles.list}>
{block.items.map((item, itemIndex) => (
<View key={itemIndex} style={styles.listItem}>
<Text style={styles.listMarker}>
{item.checked == null
? block.ordered
? `${itemIndex + 1}.`
: '-'
: item.checked
? '[x]'
: '[ ]'}
</Text>
<Text style={styles.listText}>{renderInline(item.text)}</Text>
</View>
))}
</View>
)
}
if (block.type === 'rule') {
return <View key={index} style={styles.rule} />
}
return (
<Text key={index} style={styles.paragraph}>
{block.text.split('\n').map((line, lineIndex) => (
<Fragment key={lineIndex}>
{lineIndex > 0 ? '\n' : null}
{renderInline(line)}
</Fragment>
))}
</Text>
)
})}
</View>
)
}
export const MobileMarkdown = memo(MobileMarkdownInner)
const styles = StyleSheet.create({
root: {
gap: spacing.sm
},
paragraph: {
fontSize: 13,
lineHeight: 19,
color: colors.textPrimary
},
heading: {
fontSize: 14,
lineHeight: 20,
fontWeight: '700',
color: colors.textPrimary
},
headingLarge: {
fontSize: 15,
lineHeight: 21
},
bold: {
fontWeight: '700',
color: colors.textPrimary
},
italic: {
fontStyle: 'italic'
},
strike: {
textDecorationLine: 'line-through'
},
link: {
color: colors.accentBlue,
textDecorationLine: 'underline'
},
inlineCode: {
fontFamily: typography.monoFamily,
fontSize: 12,
color: colors.textPrimary,
backgroundColor: colors.bgRaised,
borderRadius: radii.row,
paddingHorizontal: 4
},
quote: {
borderLeftWidth: 2,
borderLeftColor: colors.borderSubtle,
paddingLeft: spacing.sm
},
quoteText: {
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary
},
codeBlock: {
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
padding: spacing.sm
},
codeLanguage: {
fontSize: 10,
color: colors.textMuted,
marginBottom: spacing.xs,
textTransform: 'uppercase'
},
codeText: {
fontFamily: typography.monoFamily,
fontSize: 12,
lineHeight: 17,
color: colors.textPrimary
},
imageFrame: {
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
backgroundColor: colors.bgRaised,
overflow: 'hidden',
padding: spacing.sm
},
imageCaption: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
fontSize: 11,
color: colors.textSecondary
},
table: {
borderTopWidth: 1,
borderLeftWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
overflow: 'hidden',
backgroundColor: colors.bgPanel
},
tableRow: {
flexDirection: 'row'
},
tableCell: {
minWidth: 112,
maxWidth: 220,
borderRightWidth: 1,
borderBottomWidth: 1,
borderColor: colors.borderSubtle,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
fontSize: 12,
lineHeight: 17,
color: colors.textPrimary
},
tableHeader: {
fontWeight: '700',
backgroundColor: colors.bgRaised
},
tableTruncated: {
padding: spacing.sm,
fontSize: 12,
color: colors.textMuted
},
list: {
gap: spacing.xs
},
listItem: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm
},
listMarker: {
width: 22,
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary,
fontFamily: typography.monoFamily
},
listText: {
flex: 1,
minWidth: 0,
fontSize: 13,
lineHeight: 19,
color: colors.textPrimary
},
rule: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle
}
})
File diff suppressed because it is too large Load Diff
+38 -4
View File
@@ -1,3 +1,4 @@
import type { ReactNode } from 'react'
import { View, Text, Pressable, StyleSheet } from 'react-native'
import { Check } from 'lucide-react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
@@ -7,6 +8,8 @@ export type PickerOption<T extends string = string> = {
value: T
label: string
subtitle?: string
disabled?: boolean
renderIcon?: (selected: boolean) => ReactNode
}
type Props<T extends string = string> = {
@@ -15,7 +18,9 @@ type Props<T extends string = string> = {
options: PickerOption<T>[]
selected: T
onSelect: (value: T) => void
onLongSelect?: (value: T) => void
onClose: () => void
zIndex?: number
}
export function PickerModal<T extends string = string>({
@@ -24,10 +29,12 @@ export function PickerModal<T extends string = string>({
options,
selected,
onSelect,
onClose
onLongSelect,
onClose,
zIndex
}: Props<T>) {
return (
<BottomDrawer visible={visible} onClose={onClose}>
<BottomDrawer visible={visible} onClose={onClose} zIndex={zIndex}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
</View>
@@ -39,12 +46,30 @@ export function PickerModal<T extends string = string>({
<View key={opt.value}>
{i > 0 && <View style={styles.separator} />}
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
disabled={opt.disabled}
style={({ pressed }) => [
styles.row,
pressed && !opt.disabled && styles.rowPressed,
opt.disabled && styles.rowDisabled
]}
onPress={() => {
if (opt.disabled) return
onSelect(opt.value)
onClose()
}}
onLongPress={
onLongSelect
? () => {
if (opt.disabled) return
onLongSelect(opt.value)
onClose()
}
: undefined
}
>
{opt.renderIcon ? (
<View style={styles.rowIcon}>{opt.renderIcon(isSelected)}</View>
) : null}
<View style={styles.rowContent}>
<Text style={[styles.rowLabel, isSelected && styles.rowLabelSelected]}>
{opt.label}
@@ -90,8 +115,17 @@ const styles = StyleSheet.create({
rowPressed: {
backgroundColor: colors.bgRaised
},
rowDisabled: {
opacity: 0.45
},
rowContent: {
flex: 1
flex: 1,
minWidth: 0
},
rowIcon: {
width: 22,
alignItems: 'center',
marginRight: spacing.sm
},
rowLabel: {
fontSize: typography.bodySize,
@@ -0,0 +1,55 @@
import Svg, { Path } from 'react-native-svg'
export type TaskProviderLogoKind = 'github' | 'gitlab' | 'linear'
type Props = {
provider: TaskProviderLogoKind
size?: number
color: string
}
export function TaskProviderLogo({ provider, size = 16, color }: Props) {
if (provider === 'github') {
return (
<Svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
{/* Why: lucide-react-native omits deprecated brand icons; keep parity with desktop lucide paths. */}
<Path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4" />
<Path d="M9 18c-4.51 2-5-2-7-2" />
</Svg>
)
}
if (provider === 'gitlab') {
return (
<Svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
{/* Why: lucide-react-native omits deprecated brand icons; keep parity with desktop lucide paths. */}
<Path d="m22 13.29-3.33-10a.42.42 0 0 0-.14-.18.38.38 0 0 0-.22-.11.39.39 0 0 0-.23.07.42.42 0 0 0-.14.18l-2.26 6.67H8.32L6.1 3.26A.42.42 0 0 0 6 3.08.38.38 0 0 0 5.74 3a.39.39 0 0 0-.23.07.42.42 0 0 0-.14.18L2 13.29a.74.74 0 0 0 .27.83L12 21l9.69-6.88a.71.71 0 0 0 .31-.83Z" />
</Svg>
)
}
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
{/* Why: keep Linear's mobile glyph aligned with the desktop sidebar logo. */}
<Path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
</Svg>
)
}
@@ -0,0 +1,133 @@
export type MobileMarkdownBlock =
| { type: 'paragraph'; text: string }
| { type: 'heading'; level: number; text: string }
| { type: 'quote'; text: string }
| { type: 'code'; text: string; language?: string }
| { type: 'list'; ordered: boolean; items: Array<{ text: string; checked?: boolean }> }
| { type: 'image'; alt: string; url: string }
| { type: 'table'; headers: string[]; rows: string[][] }
| { type: 'rule' }
function splitTableRow(line: string): string[] {
return line
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim())
}
function isTableSeparator(line: string): boolean {
const cells = splitTableRow(line)
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell))
}
export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] {
const lines = content.replace(/\r\n?/g, '\n').split('\n')
const blocks: MobileMarkdownBlock[] = []
let index = 0
while (index < lines.length) {
const line = lines[index] ?? ''
if (!line.trim()) {
index += 1
continue
}
const fence = line.match(/^```([A-Za-z0-9_-]+)?\s*$/)
if (fence) {
index += 1
const code: string[] = []
while (index < lines.length && !/^```\s*$/.test(lines[index] ?? '')) {
code.push(lines[index] ?? '')
index += 1
}
if (index < lines.length) index += 1
blocks.push({ type: 'code', text: code.join('\n'), language: fence[1] })
continue
}
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
blocks.push({ type: 'rule' })
index += 1
continue
}
const standaloneImage = line.match(/^!\[([^\]]*)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)\s*$/i)
if (standaloneImage) {
blocks.push({ type: 'image', alt: standaloneImage[1] ?? '', url: standaloneImage[2]! })
index += 1
continue
}
if (
line.includes('|') &&
index + 1 < lines.length &&
isTableSeparator(lines[index + 1] ?? '')
) {
const headers = splitTableRow(line)
index += 2
const rows: string[][] = []
while (index < lines.length && (lines[index] ?? '').includes('|') && lines[index]?.trim()) {
rows.push(splitTableRow(lines[index] ?? ''))
index += 1
}
blocks.push({ type: 'table', headers, rows })
continue
}
const heading = line.match(/^(#{1,6})\s+(.+)$/)
if (heading) {
blocks.push({ type: 'heading', level: heading[1]!.length, text: heading[2]!.trim() })
index += 1
continue
}
if (/^>\s?/.test(line)) {
const quote: string[] = []
while (index < lines.length && /^>\s?/.test(lines[index] ?? '')) {
quote.push((lines[index] ?? '').replace(/^>\s?/, ''))
index += 1
}
blocks.push({ type: 'quote', text: quote.join('\n').trim() })
continue
}
if (/^\s*(?:[-*+]|\d+[.)])\s+/.test(line)) {
const items: Array<{ text: string; checked?: boolean }> = []
let ordered = false
while (index < lines.length && /^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[index] ?? '')) {
const current = lines[index] ?? ''
const orderedMatch = current.match(/^\s*\d+[.)]\s+(.+)$/)
const unorderedMatch = current.match(/^\s*[-*+]\s+(.+)$/)
ordered ||= Boolean(orderedMatch)
const rawText = (orderedMatch?.[1] ?? unorderedMatch?.[1] ?? '').trim()
const task = rawText.match(/^\[([ xX])\]\s+(.+)$/)
items.push({
text: task?.[2] ?? rawText,
checked: task ? task[1]?.toLowerCase() === 'x' : undefined
})
index += 1
}
blocks.push({ type: 'list', ordered, items })
continue
}
const paragraph: string[] = []
while (
index < lines.length &&
lines[index]?.trim() &&
!(lines[index] ?? '').startsWith('```') &&
!/^(#{1,6})\s+/.test(lines[index] ?? '') &&
!/^>\s?/.test(lines[index] ?? '') &&
!/^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[index] ?? '') &&
!/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(lines[index] ?? '')
) {
paragraph.push(lines[index] ?? '')
index += 1
}
blocks.push({ type: 'paragraph', text: paragraph.join('\n').trim() })
}
return blocks
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { buildGitHubCheckSummary } from './github-check-summary'
describe('buildGitHubCheckSummary', () => {
it('returns none for empty check lists', () => {
expect(buildGitHubCheckSummary([])).toEqual({
state: 'none',
total: 0,
passed: 0,
failed: 0,
pending: 0
})
})
it('prioritizes failed checks over pending checks', () => {
expect(
buildGitHubCheckSummary([
{ status: 'completed', conclusion: 'success' },
{ status: 'queued', conclusion: null },
{ status: 'completed', conclusion: 'timed_out' }
])
).toEqual({
state: 'failure',
total: 3,
passed: 1,
failed: 1,
pending: 1
})
})
it('marks all completed non-failing checks as successful', () => {
expect(
buildGitHubCheckSummary([
{ status: 'completed', conclusion: 'success' },
{ status: 'completed', conclusion: 'neutral' }
])
).toEqual({
state: 'success',
total: 2,
passed: 2,
failed: 0,
pending: 0
})
})
})
+45
View File
@@ -0,0 +1,45 @@
export type GitHubCheckLike = {
status: string
conclusion?: string | null
}
export type GitHubCheckSummary = {
state: 'success' | 'failure' | 'pending' | 'none'
total: number
passed: number
failed: number
pending: number
}
function isFailedCheck(check: GitHubCheckLike): boolean {
return (
check.conclusion === 'failure' ||
check.conclusion === 'timed_out' ||
check.conclusion === 'cancelled'
)
}
function isPendingCheck(check: GitHubCheckLike): boolean {
return (
check.status === 'queued' || check.status === 'in_progress' || check.conclusion === 'pending'
)
}
export function buildGitHubCheckSummary(checks: GitHubCheckLike[]): GitHubCheckSummary {
let failed = 0
let pending = 0
for (const check of checks) {
if (isFailedCheck(check)) {
failed += 1
} else if (isPendingCheck(check)) {
pending += 1
}
}
const total = checks.length
const passed = Math.max(0, total - failed - pending)
const state = total === 0 ? 'none' : failed > 0 ? 'failure' : pending > 0 ? 'pending' : 'success'
return { state, total, passed, failed, pending }
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { buildGitHubPrFileDiffLines } from './github-pr-file-diff'
describe('buildGitHubPrFileDiffLines', () => {
it('preserves context and marks added and removed lines', () => {
expect(buildGitHubPrFileDiffLines('one\ntwo\nthree\n', 'one\ntoo\nthree\n')).toEqual([
{
key: '0:context:1:1',
kind: 'context',
oldLineNumber: 1,
newLineNumber: 1,
text: 'one'
},
{
key: '1:removed:2',
kind: 'removed',
oldLineNumber: 2,
text: 'two'
},
{
key: '2:added:2',
kind: 'added',
newLineNumber: 2,
text: 'too'
},
{
key: '3:context:3:3',
kind: 'context',
oldLineNumber: 3,
newLineNumber: 3,
text: 'three'
}
])
})
it('shows added files without a fake empty original line', () => {
expect(buildGitHubPrFileDiffLines('', 'first\nsecond')).toEqual([
{ key: '0:added:1', kind: 'added', newLineNumber: 1, text: 'first' },
{ key: '1:added:2', kind: 'added', newLineNumber: 2, text: 'second' }
])
})
it('keeps all lines for large files without exact diff truncation', () => {
const original = Array.from({ length: 500 }, (_, index) => `old-${index}`).join('\n')
const modified = Array.from({ length: 500 }, (_, index) => `new-${index}`).join('\n')
const lines = buildGitHubPrFileDiffLines(original, modified)
expect(lines).toHaveLength(1000)
expect(lines[0]).toMatchObject({ kind: 'removed', oldLineNumber: 1, text: 'old-0' })
expect(lines.at(-1)).toMatchObject({ kind: 'added', newLineNumber: 500, text: 'new-499' })
})
})
+168
View File
@@ -0,0 +1,168 @@
export type GitHubPrFileDiffLine = {
key: string
kind: 'context' | 'added' | 'removed'
oldLineNumber?: number
newLineNumber?: number
text: string
}
type DiffOperation =
| { kind: 'context'; oldLine: string; newLine: string }
| { kind: 'removed'; oldLine: string }
| { kind: 'added'; newLine: string }
const EXACT_DIFF_CELL_LIMIT = 160_000
function splitContentLines(value: string): string[] {
if (!value) {
return []
}
const lines = value.split(/\r?\n/)
return lines.at(-1) === '' ? lines.slice(0, -1) : lines
}
function exactLineDiff(original: string[], modified: string[]): DiffOperation[] {
const rowWidth = modified.length + 1
const table = new Uint16Array((original.length + 1) * rowWidth)
for (let oldIndex = original.length - 1; oldIndex >= 0; oldIndex -= 1) {
for (let newIndex = modified.length - 1; newIndex >= 0; newIndex -= 1) {
const cell = oldIndex * rowWidth + newIndex
if (original[oldIndex] === modified[newIndex]) {
table[cell] = table[(oldIndex + 1) * rowWidth + newIndex + 1] + 1
} else {
table[cell] = Math.max(
table[(oldIndex + 1) * rowWidth + newIndex],
table[oldIndex * rowWidth + newIndex + 1]
)
}
}
}
const operations: DiffOperation[] = []
let oldIndex = 0
let newIndex = 0
while (oldIndex < original.length && newIndex < modified.length) {
const oldLine = original[oldIndex]
const newLine = modified[newIndex]
if (oldLine === newLine) {
operations.push({ kind: 'context', oldLine, newLine })
oldIndex += 1
newIndex += 1
continue
}
const removeScore = table[(oldIndex + 1) * rowWidth + newIndex]
const addScore = table[oldIndex * rowWidth + newIndex + 1]
if (removeScore >= addScore) {
operations.push({ kind: 'removed', oldLine })
oldIndex += 1
} else {
operations.push({ kind: 'added', newLine })
newIndex += 1
}
}
while (oldIndex < original.length) {
operations.push({ kind: 'removed', oldLine: original[oldIndex] })
oldIndex += 1
}
while (newIndex < modified.length) {
operations.push({ kind: 'added', newLine: modified[newIndex] })
newIndex += 1
}
return operations
}
function buildMiddleDiff(original: string[], modified: string[]): DiffOperation[] {
if (original.length === 0) {
return modified.map((newLine) => ({ kind: 'added', newLine }))
}
if (modified.length === 0) {
return original.map((oldLine) => ({ kind: 'removed', oldLine }))
}
if (original.length * modified.length <= EXACT_DIFF_CELL_LIMIT) {
return exactLineDiff(original, modified)
}
// Why: very large files must still show all content without an O(n*m) mobile stall.
return [
...original.map((oldLine) => ({ kind: 'removed' as const, oldLine })),
...modified.map((newLine) => ({ kind: 'added' as const, newLine }))
]
}
export function buildGitHubPrFileDiffLines(
originalContent: string,
modifiedContent: string
): GitHubPrFileDiffLine[] {
const originalLines = splitContentLines(originalContent)
const modifiedLines = splitContentLines(modifiedContent)
let prefixLength = 0
while (
prefixLength < originalLines.length &&
prefixLength < modifiedLines.length &&
originalLines[prefixLength] === modifiedLines[prefixLength]
) {
prefixLength += 1
}
let suffixLength = 0
while (
suffixLength < originalLines.length - prefixLength &&
suffixLength < modifiedLines.length - prefixLength &&
originalLines[originalLines.length - suffixLength - 1] ===
modifiedLines[modifiedLines.length - suffixLength - 1]
) {
suffixLength += 1
}
const prefix = originalLines.slice(0, prefixLength)
const originalMiddle = originalLines.slice(
prefixLength,
suffixLength === 0 ? originalLines.length : originalLines.length - suffixLength
)
const modifiedMiddle = modifiedLines.slice(
prefixLength,
suffixLength === 0 ? modifiedLines.length : modifiedLines.length - suffixLength
)
const suffix = originalLines.slice(originalLines.length - suffixLength)
const operations: DiffOperation[] = [
...prefix.map((line) => ({ kind: 'context' as const, oldLine: line, newLine: line })),
...buildMiddleDiff(originalMiddle, modifiedMiddle),
...suffix.map((line) => ({ kind: 'context' as const, oldLine: line, newLine: line }))
]
const result: GitHubPrFileDiffLine[] = []
let oldLineNumber = 1
let newLineNumber = 1
operations.forEach((operation, index) => {
if (operation.kind === 'context') {
result.push({
key: `${index}:context:${oldLineNumber}:${newLineNumber}`,
kind: 'context',
oldLineNumber,
newLineNumber,
text: operation.newLine
})
oldLineNumber += 1
newLineNumber += 1
return
}
if (operation.kind === 'removed') {
result.push({
key: `${index}:removed:${oldLineNumber}`,
kind: 'removed',
oldLineNumber,
text: operation.oldLine
})
oldLineNumber += 1
return
}
result.push({
key: `${index}:added:${newLineNumber}`,
kind: 'added',
newLineNumber,
text: operation.newLine
})
newLineNumber += 1
})
return result
}
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import {
filterGitHubProjectRowsForRepos,
findRepoForGitHubProjectRepository,
normalizeGitHubRepositorySlug
} from './github-project-repo-match'
const repos = [
{ id: 'repo-1', path: '/Users/me/orca', displayName: 'orca' },
{ id: 'repo-2', path: '/Users/me/other', displayName: 'other' }
]
describe('GitHub project repo matching', () => {
it('normalizes owner/repo slugs case-insensitively', () => {
expect(normalizeGitHubRepositorySlug(' StablyAI/Orca ')).toBe('stablyai/orca')
expect(normalizeGitHubRepositorySlug('orca')).toBeNull()
expect(normalizeGitHubRepositorySlug('stablyai/orca/extra')).toBeNull()
})
it('matches project rows by resolved repo slug before path/display heuristics', () => {
expect(
findRepoForGitHubProjectRepository('stablyai/orca', repos, {
'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' }
})
).toBe(repos[0])
})
it('does not pick a repo when resolved slugs are ambiguous', () => {
expect(
findRepoForGitHubProjectRepository('stablyai/orca', repos, {
'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' },
'repo-2': { path: '/Users/me/other', slug: 'stablyai/orca' }
})
).toBeNull()
})
it('falls back to exact display/path slug matching when slug resolution is unavailable', () => {
expect(
findRepoForGitHubProjectRepository('stablyai/orca', [
{ id: 'repo-1', path: '/Users/me/stablyai/orca', displayName: 'orca' }
])
).toEqual({ id: 'repo-1', path: '/Users/me/stablyai/orca', displayName: 'orca' })
})
it('normalizes Windows paths before path slug fallback matching', () => {
expect(
findRepoForGitHubProjectRepository('stablyai/orca', [
{ id: 'repo-1', path: 'C:\\Users\\me\\stablyai\\orca', displayName: 'orca' }
])
).toEqual({ id: 'repo-1', path: 'C:\\Users\\me\\stablyai\\orca', displayName: 'orca' })
})
it('does not path-match a repo whose resolved slug points somewhere else', () => {
expect(
findRepoForGitHubProjectRepository(
'stablyai/orca',
[{ id: 'repo-1', path: '/Users/me/stablyai/orca', displayName: 'orca' }],
{
'repo-1': { path: '/Users/me/stablyai/orca', slug: 'fork/orca' }
}
)
).toBeNull()
})
it('filters project rows to rows backed by open repositories', () => {
const rows = [
{ id: 'row-1', content: { repository: 'stablyai/orca' } },
{ id: 'row-2', content: { repository: 'other/missing' } },
{ id: 'row-3', content: { repository: null } }
]
expect(
filterGitHubProjectRowsForRepos(rows, repos, {
'repo-1': { path: '/Users/me/orca', slug: 'stablyai/orca' }
}).map((row) => row.id)
).toEqual(['row-1'])
})
})
@@ -0,0 +1,76 @@
export type GitHubProjectRepoMatch = {
id: string
path: string
displayName: string
}
export type GitHubRepoSlugCacheEntry = {
path: string
slug: string | null
}
type CachedSlugState =
| { status: 'missing' }
| { status: 'stale' }
| { status: 'resolved'; slug: string | null }
export function normalizeGitHubRepositorySlug(value: string | null | undefined): string | null {
const trimmed = value?.trim()
if (!trimmed) return null
const [owner, repo, extra] = trimmed.split('/')
if (!owner || !repo || extra) return null
return `${owner}/${repo}`.toLowerCase()
}
function cachedSlugStateForRepo(
repo: GitHubProjectRepoMatch,
slugsByRepoId: Record<string, GitHubRepoSlugCacheEntry | undefined>
): CachedSlugState {
const cached = slugsByRepoId[repo.id]
if (!cached) return { status: 'missing' }
if (cached.path !== repo.path) return { status: 'stale' }
return { status: 'resolved', slug: normalizeGitHubRepositorySlug(cached.slug) }
}
export function findRepoForGitHubProjectRepository(
repository: string | null | undefined,
repos: GitHubProjectRepoMatch[],
slugsByRepoId: Record<string, GitHubRepoSlugCacheEntry | undefined> = {}
): GitHubProjectRepoMatch | null {
const slug = normalizeGitHubRepositorySlug(repository)
if (!slug) return null
const slugStates = new Map(
repos.map((repo) => [repo.id, cachedSlugStateForRepo(repo, slugsByRepoId)])
)
const slugMatches = repos.filter((repo) => {
const state = slugStates.get(repo.id)
return state?.status === 'resolved' && state.slug === slug
})
if (slugMatches.length === 1) return slugMatches[0]!
if (slugMatches.length > 1) return null
return (
repos.find((repo) => {
const state = slugStates.get(repo.id)
if (state?.status === 'resolved' && state.slug !== null) {
return false
}
const display = repo.displayName.trim().toLowerCase()
const path = repo.path.trim().toLowerCase().replace(/\\/g, '/')
return display === slug || path.endsWith(`/${slug}`)
}) ?? null
)
}
export function filterGitHubProjectRowsForRepos<
Row extends { content: { repository?: string | null } }
>(
rows: readonly Row[],
repos: GitHubProjectRepoMatch[],
slugsByRepoId: Record<string, GitHubRepoSlugCacheEntry | undefined> = {}
): Row[] {
return rows.filter((row) =>
Boolean(findRepoForGitHubProjectRepository(row.content.repository, repos, slugsByRepoId))
)
}
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import {
extractGitHubIssueSourceError,
extractGitHubIssueSourceFallback
} from './github-work-item-source-errors'
describe('extractGitHubIssueSourceError', () => {
it('keeps the failing issue source slug with the repo that produced it', () => {
expect(
extractGitHubIssueSourceError(
{ id: 'repo-1', path: '/work/orca' },
{
sources: { issues: { owner: 'upstream', repo: 'orca' } },
errors: { issues: { message: 'HTTP 403: resource not accessible' } }
}
)
).toEqual({
repoId: 'repo-1',
repoPath: '/work/orca',
source: { owner: 'upstream', repo: 'orca' },
message: 'HTTP 403: resource not accessible'
})
})
it('drops issue errors when the source slug is unavailable', () => {
expect(
extractGitHubIssueSourceError(
{ id: 'repo-1', path: '/work/orca' },
{
sources: { issues: null },
errors: { issues: { message: 'failed' } }
}
)
).toBeNull()
})
it('returns null when the envelope has no issue-side error', () => {
expect(
extractGitHubIssueSourceError(
{ id: 'repo-1', path: '/work/orca' },
{
sources: { issues: { owner: 'stablyai', repo: 'orca' } }
}
)
).toBeNull()
})
})
describe('extractGitHubIssueSourceFallback', () => {
it('reports the repo whose upstream issue source fell back to origin', () => {
expect(
extractGitHubIssueSourceFallback(
{ id: 'repo-1', path: '/work/orca', displayName: 'orca' },
{
issueSourceFellBack: true,
sources: {
issues: { owner: 'stablyai', repo: 'orca-fork' },
prs: { owner: 'stablyai', repo: 'orca' }
}
}
)
).toEqual({
repoId: 'repo-1',
repoPath: '/work/orca',
repoLabel: 'stablyai/orca'
})
})
it('uses the Orca repo display name when the PR source is unavailable', () => {
expect(
extractGitHubIssueSourceFallback(
{ id: 'repo-1', path: '/work/orca', displayName: 'orca' },
{
issueSourceFellBack: true,
sources: { issues: null, prs: null }
}
)
).toEqual({
repoId: 'repo-1',
repoPath: '/work/orca',
repoLabel: 'orca'
})
})
it('returns null when the source resolver did not fall back', () => {
expect(
extractGitHubIssueSourceFallback(
{ id: 'repo-1', path: '/work/orca', displayName: 'orca' },
{
sources: { issues: { owner: 'stablyai', repo: 'orca' } }
}
)
).toBeNull()
})
})
@@ -0,0 +1,62 @@
export type GitHubSourceOwnerRepo = {
owner: string
repo: string
}
export type GitHubSourceErrorEnvelope = {
sources?: {
issues: GitHubSourceOwnerRepo | null
prs?: GitHubSourceOwnerRepo | null
} | null
errors?: {
issues?: {
message: string
} | null
} | null
issueSourceFellBack?: true
}
export type GitHubIssueSourceError = {
repoId: string
repoPath: string
source: GitHubSourceOwnerRepo
message: string
}
export type GitHubIssueSourceFallback = {
repoId: string
repoPath: string
repoLabel: string
}
export function extractGitHubIssueSourceError(
repo: { id: string; path: string },
envelope: GitHubSourceErrorEnvelope
): GitHubIssueSourceError | null {
const issueError = envelope.errors?.issues
const issueSource = envelope.sources?.issues
if (!issueError || !issueSource) {
return null
}
return {
repoId: repo.id,
repoPath: repo.path,
source: issueSource,
message: issueError.message
}
}
export function extractGitHubIssueSourceFallback(
repo: { id: string; path: string; displayName: string },
envelope: GitHubSourceErrorEnvelope
): GitHubIssueSourceFallback | null {
if (envelope.issueSourceFellBack !== true) {
return null
}
const prSource = envelope.sources?.prs
return {
repoId: repo.id,
repoPath: repo.path,
repoLabel: prSource ? `${prSource.owner}/${prSource.repo}` : repo.displayName
}
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { shouldResolveHostedReviewStartPoint } from './hosted-review-start-point'
describe('shouldResolveHostedReviewStartPoint', () => {
it('resolves PR and MR start points when no explicit base branch was selected', () => {
expect(shouldResolveHostedReviewStartPoint({ type: 'pr' })).toBe(true)
expect(shouldResolveHostedReviewStartPoint({ type: 'mr', baseBranchOverride: '' })).toBe(true)
})
it('does not resolve a hosted review start point when the user selected a base branch', () => {
expect(
shouldResolveHostedReviewStartPoint({
type: 'pr',
baseBranchOverride: 'origin/feature/manual'
})
).toBe(false)
expect(
shouldResolveHostedReviewStartPoint({
type: 'mr',
baseBranchOverride: 'origin/release'
})
).toBe(false)
})
it('never resolves start points for issues', () => {
expect(shouldResolveHostedReviewStartPoint({ type: 'issue' })).toBe(false)
})
})
@@ -0,0 +1,11 @@
export type HostedReviewStartPointType = 'issue' | 'pr' | 'mr'
export function shouldResolveHostedReviewStartPoint(args: {
type: HostedReviewStartPointType
baseBranchOverride?: string | null
}): boolean {
if (args.type !== 'pr' && args.type !== 'mr') {
return false
}
return !args.baseBranchOverride?.trim()
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { MOBILE_AGENT_CATALOG } from './mobile-agent-catalog'
import { MOBILE_TUI_AGENT_AUTO_PICK_ORDER } from './mobile-tui-agents'
const currentDir = dirname(fileURLToPath(import.meta.url))
function readDesktopSharedFile(relativePath: string): string {
return readFileSync(resolve(currentDir, '../../../src/shared', relativePath), 'utf8')
}
function parseDesktopAutoPickOrder(): string[] {
const source = readDesktopSharedFile('tui-agent-selection.ts')
const match = source.match(/TUI_AGENT_AUTO_PICK_ORDER = \[([\s\S]*?)\] as const/)
expect(match).not.toBeNull()
return Array.from(match?.[1].matchAll(/'([^']+)'/g) ?? [], (entry) => entry[1])
}
function parseDesktopConfiguredAgents(): string[] {
const source = readDesktopSharedFile('tui-agent-config.ts')
const match = source.match(/TUI_AGENT_CONFIG: Record<TuiAgent, TuiAgentConfig> = {([\s\S]*?)^}/m)
expect(match).not.toBeNull()
return Array.from(
match?.[1].matchAll(/^ (?:'([^']+)'|([a-z][a-z0-9-]*)): {/gm) ?? [],
(entry) => entry[1] ?? entry[2]
)
}
describe('mobile agent catalog', () => {
it('stays in the same order as desktop auto-pick and covers every configured TUI agent', () => {
const desktopAutoPickOrder = parseDesktopAutoPickOrder()
expect(MOBILE_TUI_AGENT_AUTO_PICK_ORDER).toEqual(desktopAutoPickOrder)
expect(MOBILE_AGENT_CATALOG.map((agent) => agent.id)).toEqual(desktopAutoPickOrder)
expect(new Set(MOBILE_AGENT_CATALOG.map((agent) => agent.id))).toEqual(
new Set(parseDesktopConfiguredAgents())
)
})
})
+22
View File
@@ -0,0 +1,22 @@
import type { TuiAgent } from '../../../src/shared/types'
import {
MOBILE_TUI_AGENT_AUTO_PICK_ORDER,
MOBILE_TUI_AGENT_FAVICON_DOMAINS,
MOBILE_TUI_AGENT_LABELS
} from './mobile-tui-agents'
export type MobileAgentCatalogEntry = {
id: TuiAgent
label: string
faviconDomain?: string
}
export const MOBILE_AGENT_CATALOG: MobileAgentCatalogEntry[] = MOBILE_TUI_AGENT_AUTO_PICK_ORDER.map(
(id) => ({
id,
label: MOBILE_TUI_AGENT_LABELS[id],
...(MOBILE_TUI_AGENT_FAVICON_DOMAINS[id]
? { faviconDomain: MOBILE_TUI_AGENT_FAVICON_DOMAINS[id] }
: {})
})
)
@@ -0,0 +1,32 @@
export type ComposerBranchSelection = {
baseBranch: string
branchNameOverride: string | undefined
branchAutoName: string
name: string | undefined
lastAutoName: string | undefined
}
export function resolveComposerBranchSelection(args: {
refName: string
localBranchName: string
currentName: string
lastAutoName: string
}): ComposerBranchSelection {
const shouldAutoName = !args.currentName.trim() || args.currentName === args.lastAutoName
if (!shouldAutoName) {
return {
baseBranch: args.refName,
branchNameOverride: undefined,
branchAutoName: '',
name: undefined,
lastAutoName: undefined
}
}
return {
baseBranch: args.refName,
branchNameOverride: args.localBranchName,
branchAutoName: args.localBranchName,
name: args.localBranchName,
lastAutoName: args.localBranchName
}
}
@@ -0,0 +1,261 @@
export type GitHubProjectSortDirection = 'ASC' | 'DESC'
export type GitHubProjectFieldValue =
| { kind: 'text'; text: string }
| { kind: 'number'; number: number }
| { kind: 'date'; date: string }
| { kind: 'single-select'; optionId: string; name: string }
| { kind: 'iteration'; iterationId: string; title: string }
| { kind: 'labels'; labels: Array<{ name: string }> }
| { kind: 'users'; users: Array<{ login: string }> }
export type GitHubProjectField = {
id: string
name: string
kind: string
options?: Array<{ id: string }>
iterations?: Array<{
id: string
title?: string
startDate: string
duration: number
completed: boolean
}>
}
export type GitHubProjectRow = {
position?: number | null
fieldValuesByFieldId: Record<string, GitHubProjectFieldValue>
}
export type GitHubProjectSort = {
direction: GitHubProjectSortDirection
field: GitHubProjectField
}
export type GitHubProjectTable = {
selectedView: {
groupByFields: GitHubProjectField[]
sortByFields: GitHubProjectSort[]
}
rows: GitHubProjectRow[]
}
export type ProjectGroup = {
key: string
label: string
iteration: {
startDate: string
duration: number
completed: boolean
} | null
rows: GitHubProjectRow[]
}
const EMPTY_GROUP_KEY = '__empty__'
const UNKNOWN_INDEX_SENTINEL = Number.MAX_SAFE_INTEGER
function labelForEmpty(field: GitHubProjectField): string {
return `No ${field.name}`
}
function deriveStringValue(value: GitHubProjectFieldValue): string {
switch (value.kind) {
case 'text':
return value.text
case 'number':
return String(value.number)
case 'date':
return value.date
case 'single-select':
return value.name
case 'iteration':
return value.title
case 'labels':
return value.labels.map((label) => label.name).join(', ')
case 'users':
return value.users.map((user) => user.login).join(', ')
}
}
function getFieldValueForGrouping(
row: GitHubProjectRow,
field: GitHubProjectField
): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } {
const value = row.fieldValuesByFieldId[field.id]
if (!value) {
return {
key: EMPTY_GROUP_KEY,
label: labelForEmpty(field),
orderHint: UNKNOWN_INDEX_SENTINEL,
iteration: null
}
}
if (field.kind === 'iteration' && value.kind === 'iteration') {
const iterations = field.iterations ?? []
const idx = iterations.findIndex((it) => it.id === value.iterationId)
const meta = iterations.find((it) => it.id === value.iterationId)
return {
key: value.iterationId,
label: value.title || meta?.title || 'Iteration',
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
iteration: meta
? { startDate: meta.startDate, duration: meta.duration, completed: meta.completed }
: null
}
}
if (field.kind === 'single-select' && value.kind === 'single-select') {
const idx = (field.options ?? []).findIndex((option) => option.id === value.optionId)
return {
key: value.optionId,
label: value.name,
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
iteration: null
}
}
const label = deriveStringValue(value)
return { key: `raw:${label}`, label, orderHint: 0, iteration: null }
}
export function groupRows(
table: GitHubProjectTable,
rowsInOrder: GitHubProjectRow[]
): ProjectGroup[] {
const groupField = table.selectedView.groupByFields[0]
if (!groupField) {
return [{ key: 'all', label: '', iteration: null, rows: rowsInOrder }]
}
const buckets = new Map<
string,
{
label: string
orderHint: number
iteration: ProjectGroup['iteration']
rows: GitHubProjectRow[]
}
>()
for (const row of rowsInOrder) {
const { key, label, orderHint, iteration } = getFieldValueForGrouping(row, groupField)
let bucket = buckets.get(key)
if (!bucket) {
bucket = { label, orderHint, iteration, rows: [] }
buckets.set(key, bucket)
}
bucket.rows.push(row)
}
const entries = Array.from(buckets.entries())
entries.sort((a, b) => {
if (a[0] === EMPTY_GROUP_KEY) {
return 1
}
if (b[0] === EMPTY_GROUP_KEY) {
return -1
}
if (groupField.kind === 'iteration' || groupField.kind === 'single-select') {
return a[1].orderHint - b[1].orderHint
}
return a[1].label.localeCompare(b[1].label)
})
return entries.map(([key, value]) => ({
key,
label: value.label,
iteration: value.iteration,
rows: value.rows
}))
}
function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProjectSort): number {
const field = sort.field
const aValue = a.fieldValuesByFieldId[field.id]
const bValue = b.fieldValuesByFieldId[field.id]
if (!aValue && !bValue) {
return 0
}
if (!aValue) {
return 1
}
if (!bValue) {
return -1
}
let cmp = 0
if (
field.kind === 'single-select' &&
aValue.kind === 'single-select' &&
bValue.kind === 'single-select'
) {
const options = field.options ?? []
const aIdx = options.findIndex((option) => option.id === aValue.optionId)
const bIdx = options.findIndex((option) => option.id === bValue.optionId)
cmp =
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
} else if (
field.kind === 'iteration' &&
aValue.kind === 'iteration' &&
bValue.kind === 'iteration'
) {
const iterations = field.iterations ?? []
const aIdx = iterations.findIndex((iteration) => iteration.id === aValue.iterationId)
const bIdx = iterations.findIndex((iteration) => iteration.id === bValue.iterationId)
cmp =
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
} else if (aValue.kind === 'number' && bValue.kind === 'number') {
cmp = aValue.number - bValue.number
} else if (aValue.kind === 'date' && bValue.kind === 'date') {
cmp = aValue.date.localeCompare(bValue.date)
} else if (aValue.kind === 'text' && bValue.kind === 'text') {
cmp = aValue.text.localeCompare(bValue.text)
} else if (aValue.kind === 'users' && bValue.kind === 'users') {
const aLogin = aValue.users[0]?.login ?? ''
const bLogin = bValue.users[0]?.login ?? ''
if (!aLogin && !bLogin) {
cmp = 0
} else if (!aLogin) {
cmp = 1
} else if (!bLogin) {
cmp = -1
} else {
cmp = aLogin.localeCompare(bLogin)
}
} else if (aValue.kind === 'labels' && bValue.kind === 'labels') {
const aName = aValue.labels[0]?.name ?? ''
const bName = bValue.labels[0]?.name ?? ''
if (!aName && !bName) {
cmp = 0
} else if (!aName) {
cmp = 1
} else if (!bName) {
cmp = -1
} else {
cmp = aName.localeCompare(bName)
}
} else {
return 0
}
return sort.direction === 'DESC' ? -cmp : cmp
}
export function sortRows(table: GitHubProjectTable, rows: GitHubProjectRow[]): GitHubProjectRow[] {
const sorts = table.selectedView.sortByFields
const out = [...rows]
out.sort((a, b) => {
for (const sort of sorts) {
const cmp = compareSort(a, b, sort)
if (cmp !== 0) {
return cmp
}
}
return (a.position ?? UNKNOWN_INDEX_SENTINEL) - (b.position ?? UNKNOWN_INDEX_SENTINEL)
})
return out
}
export function isIterationCurrent(iteration: { startDate: string; duration: number }): boolean {
const start = new Date(`${iteration.startDate}T00:00:00Z`).getTime()
if (Number.isNaN(start)) {
return false
}
const end = start + iteration.duration * 86_400_000
const now = Date.now()
return now >= start && now < end
}
+57
View File
@@ -0,0 +1,57 @@
export type TaskProvider = 'github' | 'gitlab' | 'linear'
export const MOBILE_TASK_PROVIDERS: readonly TaskProvider[] = ['github', 'gitlab', 'linear']
const TASK_PROVIDER_SET = new Set<TaskProvider>(MOBILE_TASK_PROVIDERS)
export function normalizeVisibleTaskProviders(value: unknown): TaskProvider[] {
if (!Array.isArray(value)) {
return [...MOBILE_TASK_PROVIDERS]
}
const normalized: TaskProvider[] = []
for (const provider of value) {
if (!TASK_PROVIDER_SET.has(provider as TaskProvider)) {
continue
}
if (!normalized.includes(provider as TaskProvider)) {
normalized.push(provider as TaskProvider)
}
}
// Why: at least one provider must remain visible so the Tasks surface always
// has a valid source to select after settings hydration or manual edits.
return normalized.length > 0 ? normalized : [...MOBILE_TASK_PROVIDERS]
}
export type TaskProviderAvailability = {
gitlabInstalled: boolean
linearConnected: boolean
}
export function filterAvailableTaskProviders(
visibleProviders: readonly TaskProvider[],
availability: TaskProviderAvailability
): TaskProvider[] {
const available = visibleProviders.filter((provider) => {
if (provider === 'github') {
return true
}
if (provider === 'gitlab') {
return availability.gitlabInstalled
}
return availability.linearConnected
})
return available.length > 0 ? available : ['github']
}
export function resolveVisibleTaskProvider(
preferred: TaskProvider | null | undefined,
visibleProviders: readonly TaskProvider[]
): TaskProvider {
if (preferred && visibleProviders.includes(preferred)) {
return preferred
}
return visibleProviders[0] ?? 'github'
}
+143
View File
@@ -0,0 +1,143 @@
import type { TuiAgent } from '../../../src/shared/types'
// Why: mobile tests run from the mobile package only, so runtime imports of
// desktop shared modules can break Vitest transforms in CI. Keep this list
// mirrored with src/shared/tui-agent-selection.ts and assert parity in tests.
export const MOBILE_TUI_AGENT_AUTO_PICK_ORDER = [
'claude',
'codex',
'grok',
'copilot',
'opencode',
'pi',
'gemini',
'antigravity',
'aider',
'goose',
'amp',
'kilo',
'kiro',
'crush',
'aug',
'autohand',
'cline',
'codebuff',
'continue',
'cursor',
'droid',
'kimi',
'mistral-vibe',
'qwen-code',
'rovo',
'hermes',
'openclaw'
] as const satisfies readonly TuiAgent[]
export const MOBILE_TUI_AGENT_LABELS: Record<TuiAgent, string> = {
claude: 'Claude',
codex: 'Codex',
grok: 'Grok',
copilot: 'GitHub Copilot',
opencode: 'OpenCode',
pi: 'Pi',
gemini: 'Gemini',
antigravity: 'Antigravity',
aider: 'Aider',
goose: 'Goose',
amp: 'Amp',
kilo: 'Kilocode',
kiro: 'Kiro',
crush: 'Charm',
aug: 'Auggie',
autohand: 'Autohand Code',
cline: 'Cline',
codebuff: 'Codebuff',
continue: 'Continue',
cursor: 'Cursor',
droid: 'Droid',
kimi: 'Kimi',
'mistral-vibe': 'Mistral Vibe',
'qwen-code': 'Qwen Code',
rovo: 'Rovo Dev',
hermes: 'Hermes',
openclaw: 'OpenClaw'
}
export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial<Record<TuiAgent, string>> = {
grok: 'x.ai',
copilot: 'github.com',
opencode: 'opencode.ai',
gemini: 'gemini.google.com',
antigravity: 'antigravity.google',
goose: 'goose-docs.ai',
amp: 'ampcode.com',
kilo: 'kilo.ai',
kiro: 'kiro.dev',
crush: 'charm.sh',
aug: 'augmentcode.com',
autohand: 'autohand.ai',
cline: 'cline.bot',
codebuff: 'codebuff.com',
continue: 'continue.dev',
cursor: 'cursor.com',
droid: 'factory.ai',
kimi: 'moonshot.cn',
'mistral-vibe': 'mistral.ai',
'qwen-code': 'qwenlm.github.io',
rovo: 'atlassian.com',
hermes: 'nousresearch.com',
openclaw: 'openclaw.ai'
}
export const MOBILE_TUI_AGENT_LAUNCH_COMMANDS: Record<TuiAgent, string> = {
claude: 'claude',
codex: 'codex',
grok: 'grok',
copilot: 'copilot',
opencode: 'opencode',
pi: 'pi',
gemini: 'gemini',
antigravity: 'agy',
aider: 'aider',
goose: 'goose',
amp: 'amp',
kilo: 'kilo',
kiro: 'kiro-cli',
crush: 'crush',
aug: 'auggie',
autohand: 'autohand',
cline: 'cline',
codebuff: 'codebuff',
continue: 'continue',
cursor: 'cursor-agent',
droid: 'droid',
kimi: 'kimi',
'mistral-vibe': 'mistral-vibe',
'qwen-code': 'qwen-code',
rovo: 'rovo',
hermes: 'hermes',
openclaw: 'openclaw'
}
export function isMobileTuiAgent(value: unknown): value is TuiAgent {
return MOBILE_TUI_AGENT_AUTO_PICK_ORDER.includes(value as TuiAgent)
}
export function pickMobileTuiAgent(
preferred: TuiAgent | 'blank' | null | undefined,
detected: Iterable<TuiAgent>
): TuiAgent | null {
if (preferred === 'blank') {
return null
}
const detectedSet = detected instanceof Set ? detected : new Set(detected)
if (preferred && detectedSet.has(preferred)) {
return preferred
}
for (const agent of MOBILE_TUI_AGENT_AUTO_PICK_ORDER) {
if (detectedSet.has(agent)) {
return agent
}
}
return null
}
+23
View File
@@ -0,0 +1,23 @@
// Why: these limits must match desktop cache/fetch behavior, but mobile cannot
// import root shared modules at runtime because Metro resolves from mobile/.
export const PER_REPO_FETCH_LIMIT = 36
export const CROSS_REPO_DISPLAY_LIMIT = 100
export const GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE =
'GitHub work items require a GitHub remote for SSH repositories'
export function isGitHubWorkItemsSshRemoteRequiredError(error: unknown): boolean {
const message =
error instanceof Error
? error.message
: typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof error.message === 'string'
? error.message
: typeof error === 'string'
? error
: ''
return message.includes(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
}
+32
View File
@@ -0,0 +1,32 @@
export function resolveMobileWorkspaceCreateName(args: {
draft: string | undefined
fallback: string
}): string {
return args.draft?.trim() || args.fallback
}
export function slugifyForWorkspaceName(input: string): string {
return input
.trim()
.toLowerCase()
.replace(/[\\/]+/g, '-')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/\.{2,}/g, '.')
.replace(/^[.-]+|[.-]+$/g, '')
.slice(0, 48)
.replace(/[-._]+$/g, '')
}
export function getLinkedWorkItemSuggestedName(item: { title: string }): string {
const withoutLeadingNumber = item.title
.trim()
.replace(/^(?:issue|pr|pull request)\s*#?\d+\s*[:-]\s*/i, '')
.replace(/^#\d+\s*[:-]\s*/, '')
.replace(/\(#\d+\)/gi, '')
.replace(/\b#\d+\b/g, '')
.trim()
const seed = withoutLeadingNumber || item.title.trim()
return slugifyForWorkspaceName(seed)
}
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import {
isSetupHookTrusted,
normalizeSetupHookTrust,
trustedOrcaHooksWithSetupApproval,
wasSetupHookPreviouslyApproved
} from './setup-hook-trust'
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types'
describe('setup hook trust', () => {
it('trusts a setup script only when the approved hash matches', () => {
const trust: PersistedTrustedOrcaHooks = {
'repo-1': { setup: { contentHash: 'hash-1', approvedAt: 1000 } }
}
expect(isSetupHookTrusted(trust, 'repo-1', 'hash-1')).toBe(true)
expect(isSetupHookTrusted(trust, 'repo-1', 'hash-2')).toBe(false)
})
it('treats an always-trusted repo as trusted for changed setup scripts', () => {
const trust: PersistedTrustedOrcaHooks = {
'repo-1': { all: { approvedAt: 1000 } }
}
expect(isSetupHookTrusted(trust, 'repo-1', 'new-hash')).toBe(true)
})
it('preserves unrelated trust entries when approving setup', () => {
const trust: PersistedTrustedOrcaHooks = {
'repo-1': {
archive: { contentHash: 'archive-hash', approvedAt: 1000 }
}
}
expect(
trustedOrcaHooksWithSetupApproval({
trust,
repoId: 'repo-1',
contentHash: 'setup-hash',
alwaysTrust: false,
approvedAt: 2000
})
).toEqual({
'repo-1': {
archive: { contentHash: 'archive-hash', approvedAt: 1000 },
setup: { contentHash: 'setup-hash', approvedAt: 2000 }
}
})
})
it('records always-trust without dropping existing script approvals', () => {
const trust: PersistedTrustedOrcaHooks = {
'repo-1': {
setup: { contentHash: 'setup-hash', approvedAt: 1000 }
}
}
expect(
trustedOrcaHooksWithSetupApproval({
trust,
repoId: 'repo-1',
contentHash: 'ignored-for-all',
alwaysTrust: true,
approvedAt: 2000
})
).toEqual({
'repo-1': {
setup: { contentHash: 'setup-hash', approvedAt: 1000 },
all: { approvedAt: 2000 }
}
})
})
it('detects previous setup approval and ignores incomplete trust payloads', () => {
expect(
wasSetupHookPreviouslyApproved(
{ 'repo-1': { setup: { contentHash: 'hash-1', approvedAt: 1000 } } },
'repo-1'
)
).toBe(true)
expect(normalizeSetupHookTrust({ contentHash: 'hash-1', scriptContent: '' })).toBe(null)
expect(
normalizeSetupHookTrust({ contentHash: 'hash-1', scriptContent: 'pnpm install' })
).toEqual({
contentHash: 'hash-1',
scriptContent: 'pnpm install'
})
})
})
+46
View File
@@ -0,0 +1,46 @@
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types'
export type SetupHookTrust = {
contentHash: string
scriptContent: string
}
export function isSetupHookTrusted(
trust: PersistedTrustedOrcaHooks,
repoId: string,
contentHash: string
): boolean {
const repoTrust = trust[repoId]
return Boolean(repoTrust?.all || repoTrust?.setup?.contentHash === contentHash)
}
export function wasSetupHookPreviouslyApproved(
trust: PersistedTrustedOrcaHooks,
repoId: string
): boolean {
return Boolean(trust[repoId]?.setup?.contentHash)
}
export function trustedOrcaHooksWithSetupApproval(args: {
trust: PersistedTrustedOrcaHooks
repoId: string
contentHash: string
alwaysTrust: boolean
approvedAt?: number
}): PersistedTrustedOrcaHooks {
const approvedAt = args.approvedAt ?? Date.now()
const existing = args.trust[args.repoId]
const nextRepo = args.alwaysTrust
? { ...existing, all: { approvedAt } }
: { ...existing, setup: { contentHash: args.contentHash, approvedAt } }
return { ...args.trust, [args.repoId]: nextRepo }
}
export function normalizeSetupHookTrust(
setupTrust: SetupHookTrust | null | undefined
): SetupHookTrust | null {
if (!setupTrust?.contentHash || !setupTrust.scriptContent) {
return null
}
return setupTrust
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { parseSparsePresetDirectories } from './sparse-preset-draft'
describe('parseSparsePresetDirectories', () => {
it('normalizes textarea input into unique repo-relative directories', () => {
expect(
parseSparsePresetDirectories(`
src\\renderer
packages/ui/
src/renderer
`)
).toEqual({
directories: ['src/renderer', 'packages/ui'],
error: null
})
})
it('requires at least one directory', () => {
expect(parseSparsePresetDirectories(' \n ')).toEqual({
directories: [],
error: 'Add at least one directory.'
})
})
it('rejects root and parent path entries', () => {
expect(parseSparsePresetDirectories('.')).toEqual({
directories: [],
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
})
expect(parseSparsePresetDirectories('src/../packages')).toEqual({
directories: [],
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
})
expect(parseSparsePresetDirectories('/')).toEqual({
directories: [],
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
})
})
it.each(['/Users/me/repo/packages/web', 'C:\\repo\\packages\\web', '\\\\server\\share\\repo'])(
'rejects absolute directory input before normalization: %s',
(entry) => {
expect(parseSparsePresetDirectories(entry)).toEqual({
directories: [],
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
})
}
)
})
+67
View File
@@ -0,0 +1,67 @@
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:/
export type SparsePresetDirectoryParseResult = {
directories: string[]
error: string | null
}
function isAbsoluteSparseDirectoryPath(value: string): boolean {
const entry = value.trim()
return entry.startsWith('/') || entry.startsWith('\\') || WINDOWS_DRIVE_PATH_PATTERN.test(entry)
}
function normalizeSparseDirectoryLines(value: string): string[] {
const seen = new Set<string>()
return value
.split('\n')
.map((entry) =>
entry
.trim()
.replace(/\\/g, '/')
.replace(/^\/+|\/+$/g, '')
)
.filter((entry) => entry.length > 0)
.filter((entry) => {
if (seen.has(entry)) {
return false
}
seen.add(entry)
return true
})
}
export function parseSparsePresetDirectories(value: string): SparsePresetDirectoryParseResult {
const rawEntries = value
.split('\n')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
// Why: absolute paths can look repo-relative after slash normalization.
if (rawEntries.some(isAbsoluteSparseDirectoryPath)) {
return {
directories: [],
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
}
}
const directories = normalizeSparseDirectoryLines(value)
if (directories.length === 0) {
return {
directories,
error: 'Add at least one directory.'
}
}
if (directories.some((entry) => entry === '.' || entry.split('/').includes('..'))) {
return {
directories: [],
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
}
}
return {
directories,
error: null
}
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import {
normalizeWorkspaceAgent,
pickWorkspaceAgent,
workspaceAgentLabel
} from './workspace-agent-selection'
describe('workspace agent selection', () => {
it('uses an installed explicit default agent', () => {
expect(pickWorkspaceAgent({ defaultTuiAgent: 'codex' }, new Set(['claude', 'codex']))).toBe(
'codex'
)
})
it('falls back by desktop auto-pick order when the default is unavailable on the target host', () => {
expect(pickWorkspaceAgent({ defaultTuiAgent: 'codex' }, new Set(['claude']))).toBe('claude')
})
it('honors blank terminal as an explicit no-agent preference', () => {
expect(pickWorkspaceAgent({ defaultTuiAgent: 'blank' }, new Set(['claude', 'codex']))).toBe(
'blank'
)
})
it('returns blank when detection completed and no known agent exists', () => {
expect(pickWorkspaceAgent({ defaultTuiAgent: null }, new Set(['unknown-agent']))).toBe('blank')
})
it('uses the preferred/default display value while detection is still pending', () => {
expect(pickWorkspaceAgent({ defaultTuiAgent: 'codex' }, null)).toBe('codex')
expect(pickWorkspaceAgent({ defaultTuiAgent: null }, null)).toBe('claude')
})
it('normalizes legacy blank sentinel and labels known choices', () => {
expect(normalizeWorkspaceAgent('__blank__')).toBe('blank')
expect(workspaceAgentLabel('codex')).toBe('Codex')
})
})
@@ -0,0 +1,37 @@
import type { TuiAgent } from '../../../src/shared/types'
import {
isMobileTuiAgent,
MOBILE_TUI_AGENT_AUTO_PICK_ORDER,
MOBILE_TUI_AGENT_LABELS,
pickMobileTuiAgent
} from './mobile-tui-agents'
export type WorkspaceAgentChoice = TuiAgent | 'blank'
export function workspaceAgentLabel(agent: WorkspaceAgentChoice): string {
return agent === 'blank' ? 'Blank Terminal' : MOBILE_TUI_AGENT_LABELS[agent]
}
export function normalizeWorkspaceAgent(value: unknown): WorkspaceAgentChoice | null {
if (value === 'blank' || value === '__blank__') {
return 'blank'
}
return isMobileTuiAgent(value) ? value : null
}
export function pickWorkspaceAgent(
settings: { defaultTuiAgent?: TuiAgent | 'blank' | null },
detectedAgentIds: Set<string> | null
): WorkspaceAgentChoice {
const preferred = normalizeWorkspaceAgent(settings.defaultTuiAgent)
if (preferred === 'blank') {
return preferred
}
if (detectedAgentIds === null) {
return preferred ?? MOBILE_TUI_AGENT_AUTO_PICK_ORDER[0] ?? 'blank'
}
const detectedAgents = MOBILE_TUI_AGENT_AUTO_PICK_ORDER.filter((agent) =>
detectedAgentIds.has(agent)
)
return pickMobileTuiAgent(preferred, detectedAgents) ?? 'blank'
}
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import { buildTaskWorkspaceCreateParams } from './workspace-create-params'
describe('task workspace create params', () => {
it('passes a GitHub PR URL as an agent draft and links the PR', () => {
expect(
buildTaskWorkspaceCreateParams({
item: {
provider: 'github',
source: {
type: 'pr',
repoId: 'repo-1',
number: 123,
title: 'Fix mobile tasks',
url: 'https://github.com/acme/app/pull/123'
}
},
targetRepoId: 'ignored-for-github',
setupDecision: 'run',
agent: 'codex',
workspaceName: ' mobile-tasks ',
hostedStartPoint: {
baseBranch: 'origin/main',
pushTarget: { remoteName: 'origin', branchName: 'feature/mobile-tasks' }
}
})
).toMatchObject({
repo: 'id:repo-1',
name: 'mobile-tasks',
displayName: 'Fix mobile tasks',
setupDecision: 'run',
activate: true,
startupDraft: 'https://github.com/acme/app/pull/123',
createdWithAgent: 'codex',
linkedPR: 123,
baseBranch: 'origin/main',
pushTarget: { remoteName: 'origin', branchName: 'feature/mobile-tasks' }
})
})
it('omits startup draft and agent when blank terminal is selected', () => {
const params = buildTaskWorkspaceCreateParams({
item: {
provider: 'github',
source: {
type: 'issue',
repoId: 'repo-1',
number: 88,
title: 'Investigate login',
url: 'https://github.com/acme/app/issues/88'
}
},
targetRepoId: 'ignored-for-github',
setupDecision: 'skip',
agent: 'blank'
})
expect(params).toMatchObject({
repo: 'id:repo-1',
name: 'issue-88',
displayName: 'Investigate login',
setupDecision: 'skip',
activate: true,
linkedIssue: 88
})
expect(params).not.toHaveProperty('startupDraft')
expect(params).not.toHaveProperty('createdWithAgent')
})
it('keeps the startup draft when no agent was provided so the host can auto-pick', () => {
const params = buildTaskWorkspaceCreateParams({
item: {
provider: 'github',
source: {
type: 'issue',
repoId: 'repo-1',
number: 89,
title: 'Auto-pick agent',
url: 'https://github.com/acme/app/issues/89'
}
},
targetRepoId: 'ignored-for-github',
setupDecision: 'inherit'
})
expect(params).toMatchObject({
startupDraft: 'https://github.com/acme/app/issues/89',
linkedIssue: 89
})
expect(params).not.toHaveProperty('createdWithAgent')
})
it('links GitLab merge requests and carries explicit base branch overrides', () => {
expect(
buildTaskWorkspaceCreateParams({
item: {
provider: 'gitlab',
source: {
type: 'mr',
repoId: 'repo-2',
number: 7,
title: 'Port drawer',
url: 'https://gitlab.com/acme/app/-/merge_requests/7'
}
},
targetRepoId: 'ignored-for-gitlab',
setupDecision: 'inherit',
agent: 'claude',
baseBranch: 'origin/release',
hostedStartPoint: { baseBranch: 'origin/main' },
branchNameOverride: 'port-drawer',
sparseCheckout: { directories: ['mobile'], presetId: 'preset-1' },
note: ' keep mobile parity '
})
).toMatchObject({
repo: 'id:repo-2',
name: 'mr-7',
displayName: 'Port drawer',
startupDraft: 'https://gitlab.com/acme/app/-/merge_requests/7',
createdWithAgent: 'claude',
linkedGitLabMR: 7,
baseBranch: 'origin/release',
branchNameOverride: 'port-drawer',
sparseCheckout: { directories: ['mobile'], presetId: 'preset-1' },
comment: 'keep mobile parity'
})
})
it('creates Linear workspaces in the selected repo and links the identifier', () => {
expect(
buildTaskWorkspaceCreateParams({
item: {
provider: 'linear',
source: {
identifier: 'ENG-42',
title: 'Ship Linear parity',
url: 'https://linear.app/acme/issue/ENG-42/ship-linear-parity'
}
},
targetRepoId: 'repo-linear',
setupDecision: 'inherit',
agent: 'grok'
})
).toMatchObject({
repo: 'id:repo-linear',
name: 'eng-42',
displayName: 'Ship Linear parity',
linkedLinearIssue: 'ENG-42',
startupDraft: 'https://linear.app/acme/issue/ENG-42/ship-linear-parity',
createdWithAgent: 'grok'
})
})
})
+137
View File
@@ -0,0 +1,137 @@
import type { TuiAgent } from '../../../src/shared/types'
import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name'
import type { WorkspaceAgentChoice } from './workspace-agent-selection'
export type WorkspaceCreateSetupDecision = 'inherit' | 'run' | 'skip'
export type WorkspaceCreateSparseCheckout = {
directories: string[]
presetId?: string
}
export type WorkspaceCreateGitPushTarget = {
remoteName: string
branchName: string
remoteUrl?: string
}
export type WorkspaceCreateHostedStartPoint = {
baseBranch: string
pushTarget?: WorkspaceCreateGitPushTarget
}
type WorkspaceCreateGitHubItem = {
provider: 'github'
source: {
type: 'issue' | 'pr'
repoId: string
number: number
title: string
url: string
}
}
type WorkspaceCreateGitLabItem = {
provider: 'gitlab'
source: {
type: 'issue' | 'mr'
repoId: string
number: number
title: string
url: string
}
}
type WorkspaceCreateLinearItem = {
provider: 'linear'
source: {
identifier: string
title: string
url: string
}
}
export type WorkspaceCreateTaskItem =
| WorkspaceCreateGitHubItem
| WorkspaceCreateGitLabItem
| WorkspaceCreateLinearItem
export type WorkspaceCreateParams = Record<string, unknown>
export function buildTaskWorkspaceCreateParams(args: {
item: WorkspaceCreateTaskItem
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
agent?: WorkspaceAgentChoice
workspaceName?: string
note?: string
baseBranch?: string
branchNameOverride?: string
sparseCheckout?: WorkspaceCreateSparseCheckout
hostedStartPoint?: WorkspaceCreateHostedStartPoint
}): WorkspaceCreateParams {
const {
item,
targetRepoId,
setupDecision,
agent,
workspaceName,
note,
baseBranch,
branchNameOverride,
sparseCheckout,
hostedStartPoint
} = args
const shouldLaunchAgent = agent !== 'blank'
const createdWithAgent = shouldLaunchAgent ? (agent as TuiAgent) : undefined
const comment = note?.trim()
const selectedBaseBranch = baseBranch || hostedStartPoint?.baseBranch
const common = {
setupDecision,
activate: true,
...(shouldLaunchAgent ? { startupDraft: item.source.url } : {}),
...(createdWithAgent ? { createdWithAgent } : {}),
...(selectedBaseBranch ? { baseBranch: selectedBaseBranch } : {}),
...(branchNameOverride ? { branchNameOverride } : {}),
...(hostedStartPoint?.pushTarget ? { pushTarget: hostedStartPoint.pushTarget } : {}),
...(sparseCheckout ? { sparseCheckout } : {}),
...(comment ? { comment } : {})
}
if (item.provider === 'github') {
const fallback = `${item.source.type}-${item.source.number}`
return {
repo: `id:${item.source.repoId}`,
name: resolveMobileWorkspaceCreateName({ draft: workspaceName, fallback }),
displayName: item.source.title,
...common,
...(item.source.type === 'issue'
? { linkedIssue: item.source.number }
: { linkedPR: item.source.number })
}
}
if (item.provider === 'gitlab') {
const fallback = `${item.source.type}-${item.source.number}`
return {
repo: `id:${item.source.repoId}`,
name: resolveMobileWorkspaceCreateName({ draft: workspaceName, fallback }),
displayName: item.source.title,
...common,
...(item.source.type === 'issue'
? { linkedGitLabIssue: item.source.number }
: { linkedGitLabMR: item.source.number })
}
}
return {
repo: `id:${targetRepoId}`,
name: resolveMobileWorkspaceCreateName({
draft: workspaceName,
fallback: item.source.identifier.toLowerCase()
}),
displayName: item.source.title,
linkedLinearIssue: item.source.identifier,
...common
}
}
@@ -0,0 +1,3 @@
// Why: desktop remote worktree creation uses the same 10-minute RPC budget.
// SSH clone/setup/startup can legitimately exceed the generic 30s mobile RPC timeout.
export const WORKTREE_CREATE_TIMEOUT_MS = 10 * 60_000
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from './workspace-ssh-gate'
describe('workspace SSH gate', () => {
it('does not gate local repositories', () => {
expect(
deriveWorkspaceSshGate({
connectionId: null,
state: null,
connecting: false
})
).toEqual({
status: null,
requiresConnection: false,
connectInProgress: false,
error: null
})
})
it('requires connection for remote repos until the matching target is connected', () => {
expect(
deriveWorkspaceSshGate({
connectionId: 'ssh-1',
state: null,
connecting: false
})
).toMatchObject({ status: null, requiresConnection: true })
expect(
deriveWorkspaceSshGate({
connectionId: 'ssh-1',
state: { targetId: 'ssh-1', status: 'connected', error: null, reconnectAttempt: 0 },
connecting: false
})
).toMatchObject({ status: 'connected', requiresConnection: false })
})
it('ignores stale SSH state from a previously selected repository', () => {
expect(
deriveWorkspaceSshGate({
connectionId: 'ssh-2',
state: { targetId: 'ssh-1', status: 'connected', error: null, reconnectAttempt: 0 },
connecting: false
})
).toEqual({
status: null,
requiresConnection: true,
connectInProgress: false,
error: null
})
})
it('marks connecting, relay deploy, and reconnect states as in progress', () => {
for (const status of ['connecting', 'deploying-relay', 'reconnecting'] as const) {
expect(
deriveWorkspaceSshGate({
connectionId: 'ssh-1',
state: { targetId: 'ssh-1', status, error: null, reconnectAttempt: 0 },
connecting: false
})
).toMatchObject({ requiresConnection: true, connectInProgress: true })
}
})
it('keeps auth and relay errors visible in the drawer', () => {
expect(
deriveWorkspaceSshGate({
connectionId: 'ssh-1',
state: {
targetId: 'ssh-1',
status: 'auth-failed',
error: 'Permission denied',
reconnectAttempt: 0
},
connecting: false
})
).toMatchObject({
status: 'auth-failed',
requiresConnection: true,
connectInProgress: false,
error: 'Permission denied'
})
})
it('labels user-visible SSH states', () => {
expect(workspaceSshStatusLabel(null)).toBe('Disconnected')
expect(workspaceSshStatusLabel('deploying-relay')).toBe('Deploying relay')
expect(workspaceSshStatusLabel('auth-failed')).toBe('Authentication failed')
expect(workspaceSshStatusLabel('connected')).toBe('Connected')
})
})
+39
View File
@@ -0,0 +1,39 @@
import type { SshConnectionState, SshConnectionStatus } from '../../../src/shared/ssh-types'
export type WorkspaceSshGate = {
status: SshConnectionStatus | null
requiresConnection: boolean
connectInProgress: boolean
error: string | null
}
export function isWorkspaceSshConnectInProgress(status: SshConnectionStatus | null): boolean {
return status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting'
}
export function workspaceSshStatusLabel(status: SshConnectionStatus | null): string {
if (status === 'connected') return 'Connected'
if (status === 'connecting') return 'Connecting'
if (status === 'deploying-relay') return 'Deploying relay'
if (status === 'reconnecting') return 'Reconnecting'
if (status === 'auth-failed') return 'Authentication failed'
if (status === 'reconnection-failed') return 'Reconnect failed'
if (status === 'error') return 'Connection failed'
return 'Disconnected'
}
export function deriveWorkspaceSshGate(args: {
connectionId: string | null
state: SshConnectionState | null
connecting: boolean
}): WorkspaceSshGate {
const matchingState =
args.connectionId && args.state?.targetId === args.connectionId ? args.state : null
const status = matchingState?.status ?? null
return {
status,
requiresConnection: args.connectionId !== null && status !== 'connected',
connectInProgress: args.connecting || isWorkspaceSshConnectInProgress(status),
error: matchingState?.error ?? null
}
}
+5
View File
@@ -21,6 +21,11 @@ vi.mock('./gh-utils', () => ({
getOwnerRepo: getOwnerRepoMock,
getIssueOwnerRepo: vi.fn(),
getOwnerRepoForRemote: vi.fn(),
githubRepoContext: vi.fn((repoPath: string, connectionId?: string | null) => ({
repoPath,
connectionId: connectionId ?? null
})),
ghRepoExecOptions: vi.fn((context: { repoPath: string }) => ({ cwd: context.repoPath })),
gitExecFileAsync: vi.fn(),
extractExecError: extractExecErrorMock,
parseGitHubOwnerRepo: vi.fn(),
+68 -8
View File
@@ -1362,7 +1362,9 @@ async function findOpenPRByHeadBase(args: {
ownerRepo: OwnerRepo
head: string
base: string
connectionId?: string | null
}): Promise<{ number: number; url: string } | null> {
const context = githubRepoContext(args.repoPath, args.connectionId)
const { stdout } = await ghExecFileAsync(
[
'pr',
@@ -1380,7 +1382,7 @@ async function findOpenPRByHeadBase(args: {
'--json',
'number,url'
],
{ cwd: args.repoPath }
ghRepoExecOptions(context)
)
const list = JSON.parse(stdout) as { number?: number; url?: string }[]
if (list.length !== 1 || !list[0]?.number || !list[0]?.url) {
@@ -1391,7 +1393,8 @@ async function findOpenPRByHeadBase(args: {
export async function createGitHubPullRequest(
repoPath: string,
input: CreateHostedReviewInput
input: CreateHostedReviewInput,
connectionId?: string | null
): Promise<CreateHostedReviewResult> {
if (input.provider !== 'github') {
return {
@@ -1401,7 +1404,7 @@ export async function createGitHubPullRequest(
}
}
const ownerRepo = await getOwnerRepo(repoPath)
const ownerRepo = await getOwnerRepo(repoPath, connectionId)
if (!ownerRepo) {
return {
ok: false,
@@ -1452,8 +1455,9 @@ export async function createGitHubPullRequest(
createArgs.push('--draft')
}
try {
const context = githubRepoContext(repoPath, connectionId)
const { stdout } = await ghExecFileAsync(createArgs, {
cwd: repoPath,
...ghRepoExecOptions(context),
timeout: 60_000,
idempotent: false
})
@@ -1462,7 +1466,9 @@ export async function createGitHubPullRequest(
return { ok: true, ...created }
}
const found = head
? await findOpenPRByHeadBase({ repoPath, ownerRepo, head, base }).catch(() => null)
? await findOpenPRByHeadBase({ repoPath, ownerRepo, head, base, connectionId }).catch(
() => null
)
: null
if (found) {
return { ok: true, ...found }
@@ -1479,9 +1485,13 @@ export async function createGitHubPullRequest(
(classified.code === 'already_exists' || classified.code === 'unknown_completion') &&
head
) {
const existing = await findOpenPRByHeadBase({ repoPath, ownerRepo, head, base }).catch(
() => null
)
const existing = await findOpenPRByHeadBase({
repoPath,
ownerRepo,
head,
base,
connectionId
}).catch(() => null)
if (existing) {
return {
ok: false,
@@ -3006,3 +3016,53 @@ export async function updatePRTitle(
release()
}
}
export async function updatePRDetails(
repoPath: string,
prNumber: number,
updates: { title?: string; body?: string },
connectionId?: string | null,
prRepo?: OwnerRepo | null
): Promise<{ ok: true } | { ok: false; error: string }> {
const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId))
const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId))
if (!ownerRepo) {
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
}
const fields: string[] = []
if (updates.title !== undefined) {
const title = updates.title.trim()
if (!title) {
return { ok: false, error: 'Title is required' }
}
fields.push(`title=${title}`)
}
if (updates.body !== undefined) {
fields.push(`body=${updates.body}`)
}
if (fields.length === 0) {
return { ok: true }
}
await acquire()
try {
await ghExecFileAsync(
[
'api',
'-X',
'PATCH',
`repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${prNumber}`,
...fields.flatMap((field) => ['--raw-field', field])
],
ghOptions
)
return { ok: true }
} catch (err) {
const message =
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error'
return { ok: false, error: classifyGhError(message).message }
} finally {
release()
}
}
+1 -1
View File
@@ -839,7 +839,7 @@ export async function getWorkItemDetailsBySlug(
// Why: PR files/checks/review-thread tabs depend on a local repo path and
// are out of Project-mode slug scope for v1. Omit them here; the dialog
// branches on their absence and hides those tabs.
...(args.type === 'issue' ? { assignees } : {})
assignees
}
return { ok: true, details }
}
+83 -71
View File
@@ -35,7 +35,7 @@ vi.mock('./gl-utils', async () => {
}
})
import { getMergeRequest, getMergeRequestForBranch, listMergeRequests } from './client'
import { getMergeRequest, getMergeRequestForBranch, listMergeRequests, updateMR } from './client'
describe('gitlab client — MR operations', () => {
beforeEach(() => {
@@ -48,6 +48,10 @@ describe('gitlab client — MR operations', () => {
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
getGlabKnownHostsMock.mockResolvedValue(['gitlab.com'])
resolveIssueSourceMock.mockResolvedValue({
source: { host: 'gitlab.com', path: 'g/p' },
fellBack: false
})
})
describe('getMergeRequest', () => {
@@ -193,15 +197,14 @@ describe('gitlab client — MR operations', () => {
describe('listMergeRequests', () => {
beforeEach(() => {
resolveIssueSourceMock.mockImplementation(async () => ({
source: await getProjectRefMock(),
source: { host: 'gitlab.com', path: 'g/p' },
fellBack: false
}))
})
it('returns MRs via glab CLI', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
it('returns MRs via the GitLab API', async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({
body: JSON.stringify([
{
id: 100,
iid: 1,
@@ -215,7 +218,8 @@ describe('gitlab client — MR operations', () => {
source_project_id: 5,
target_project_id: 5
}
])
]),
headers: { 'x-total': '1', 'x-total-pages': '1' }
})
const result = await listMergeRequests('/repo', 'opened', 1, 20)
@@ -231,58 +235,35 @@ describe('gitlab client — MR operations', () => {
isCrossRepository: false,
repoId: 'g/p'
})
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
expect(glabApiWithHeadersMock).toHaveBeenCalledWith(
[
'mr',
'list',
'--output',
'json',
'--per-page',
'20',
'--page',
'1',
'--order',
'updated_at',
'--sort',
'desc',
'--repo',
'https://gitlab.com/g/p'
'projects/g%2Fp/merge_requests?page=1&per_page=20&order_by=updated_at&sort=desc&with_merge_status_recheck=false&state=opened'
],
{ cwd: '/repo' }
)
})
it("passes --all when state='all'", async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
it("omits state when state='all'", async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
await listMergeRequests('/repo', 'all', 1, 20)
const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[]
expect(callArgs).toContain('--all')
expect(callArgs).not.toContain('--opened')
expect(callArgs).not.toContain('--merged')
expect(callArgs).not.toContain('--closed')
const callArgs = glabApiWithHeadersMock.mock.calls[0][0] as string[]
expect(callArgs[0]).not.toContain('state=')
})
it('passes through Open / Merged / Closed states as flags', async () => {
it('passes through Open / Merged / Closed states as API params', async () => {
for (const state of ['opened', 'merged', 'closed'] as const) {
glabExecFileAsyncMock.mockReset()
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
glabApiWithHeadersMock.mockReset()
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
await listMergeRequests('/repo', state, 1, 20)
const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[]
if (state === 'opened') {
expect(callArgs).not.toContain('--opened')
} else {
expect(callArgs).toContain(`--${state}`)
}
const callArgs = glabApiWithHeadersMock.mock.calls[0][0] as string[]
expect(callArgs[0]).toContain(`state=${state}`)
}
})
it('flags fork MRs as cross-repository', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
glabApiWithHeadersMock.mockResolvedValueOnce({
body: JSON.stringify([
{
id: 200,
iid: 2,
@@ -293,47 +274,78 @@ describe('gitlab client — MR operations', () => {
source_project_id: 11,
target_project_id: 5
}
])
]),
headers: {}
})
const result = await listMergeRequests('/repo', 'opened', 1, 20)
expect(result.items[0].isCrossRepository).toBe(true)
})
it('falls back to CLI when project ref is unresolved', async () => {
getProjectRefMock.mockResolvedValueOnce(null)
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
id: 100,
iid: 1,
title: 'fallback mr',
state: 'opened',
web_url: 'https://gitlab.example.com/fallback/-/merge_requests/1',
updated_at: '2026-05-05',
source_branch: 'feat',
target_branch: 'main',
author: { username: 'alice' },
source_project_id: 5,
target_project_id: 5
}
])
it('returns a not_found envelope when project ref is unresolved', async () => {
resolveIssueSourceMock.mockResolvedValueOnce({
source: null,
fellBack: false
})
const result = await listMergeRequests('/repo', 'opened')
expect(result.items).toHaveLength(1)
expect(result.items[0].title).toBe('fallback mr')
const callArgs = glabExecFileAsyncMock.mock.calls[0][0] as string[]
expect(callArgs).toContain('--order')
expect(callArgs).toContain('updated_at')
expect(callArgs).not.toContain('--repo')
expect(result.items).toEqual([])
expect(result.error?.type).toBe('not_found')
expect(glabApiWithHeadersMock).not.toHaveBeenCalled()
})
it('classifies CLI errors into the result envelope', async () => {
getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' })
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
it('classifies API errors into the result envelope', async () => {
glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
const result = await listMergeRequests('/repo', 'opened')
expect(result.error?.type).toBe('permission_denied')
expect(result.items).toEqual([])
})
})
describe('updateMR', () => {
beforeEach(() => {
resolveIssueSourceMock.mockImplementation(async () => ({
source: { host: 'git.internal', path: 'g/p' },
fellBack: false
}))
})
it('updates title, body, and labels through the selected SSH GitLab host', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '{}' })
await expect(
updateMR(
'/repo',
12,
{
title: 'Renamed',
body: 'Updated body',
addLabels: ['bug'],
removeLabels: ['stale']
},
'upstream',
'conn-1'
)
).resolves.toEqual({ ok: true })
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
[
'api',
'--hostname',
'git.internal',
'-X',
'PUT',
'projects/g%2Fp/merge_requests/12',
'-f',
'title=Renamed',
'-f',
'description=Updated body',
'-f',
'add_labels=bug',
'-f',
'remove_labels=stale'
],
{}
)
})
})
})
+52 -29
View File
@@ -53,22 +53,21 @@ describe('gitlab client — combined listWorkItems', () => {
})
it('merges MRs + issues and sorts by updatedAt desc', async () => {
glabExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'mr') {
return {
stdout: JSON.stringify([
{
id: 100,
iid: 1,
title: 'older mr',
state: 'opened',
updated_at: '2026-05-05T00:00:00Z',
source_project_id: 5,
target_project_id: 5
}
])
glabApiWithHeadersMock.mockResolvedValueOnce({
body: JSON.stringify([
{
id: 100,
iid: 1,
title: 'older mr',
state: 'opened',
updated_at: '2026-05-05T00:00:00Z',
source_project_id: 5,
target_project_id: 5
}
}
]),
headers: {}
})
glabExecFileAsyncMock.mockImplementation(async () => {
return {
stdout: JSON.stringify([
{
@@ -89,13 +88,13 @@ describe('gitlab client — combined listWorkItems', () => {
})
it("skips the issues fetch when state === 'merged'", async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
await listWorkItems('/repo', 'merged', 1, 20)
// Why: the merged-state filter doesn't apply to issues (issues
// don't have a merged lifecycle), so the IPC must not even spawn
// the issues read. Verifies the listIssues path was not taken.
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
it('passes the closed state through to the issues fetch', async () => {
@@ -104,11 +103,20 @@ describe('gitlab client — combined listWorkItems', () => {
})
await listWorkItems('/repo', 'closed', 1, 20)
const issuesCalls = glabExecFileAsyncMock.mock.calls.filter(
(call) => (call[0] as string[])[0] === 'api'
)
expect(issuesCalls).toHaveLength(1)
expect((issuesCalls[0][0] as string[])[1]).toContain('state=closed')
const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[]
expect(issuesCallPath.at(-1)).toContain('state=closed')
})
it('passes search queries through to merge request and issue fetches', async () => {
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listWorkItems('/repo', 'opened', 1, 20, undefined, 'ambiguous selector')
const mergeRequestCallPath = glabApiWithHeadersMock.mock.calls[0][0] as string[]
const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[]
expect(mergeRequestCallPath[0]).toContain('search=ambiguous%20selector')
expect(issuesCallPath.at(-1)).toContain('search=ambiguous%20selector')
})
it("omits the state param when 'all'", async () => {
@@ -117,11 +125,26 @@ describe('gitlab client — combined listWorkItems', () => {
})
await listWorkItems('/repo', 'all', 1, 20)
const issuesCalls = glabExecFileAsyncMock.mock.calls.filter(
(call) => (call[0] as string[])[0] === 'api'
)
expect(issuesCalls).toHaveLength(1)
expect((issuesCalls[0][0] as string[])[1]).not.toContain('state=')
const issuesCallPath = glabExecFileAsyncMock.mock.calls[0][0] as string[]
expect(issuesCallPath.at(-1)).not.toContain('state=')
})
it('routes issue list fetches through the selected SSH GitLab host', async () => {
resolveIssueSourceMock.mockResolvedValueOnce({
source: { host: 'git.internal', path: 'g/p' },
fellBack: false
})
glabApiWithHeadersMock.mockResolvedValueOnce({ body: '[]', headers: {} })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listWorkItems('/repo', 'opened', 1, 20, 'upstream', undefined, 'conn-1')
expect(glabExecFileAsyncMock.mock.calls[0][0]).toEqual([
'api',
'--hostname',
'git.internal',
'projects/g%2Fp/issues?per_page=20&order_by=updated_at&sort=desc&state=opened'
])
})
it('returns a not_found error envelope when project ref is unresolved', async () => {
@@ -134,7 +157,7 @@ describe('gitlab client — combined listWorkItems', () => {
})
it('surfaces the MR error envelope into the combined result', async () => {
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 403 Forbidden'))
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
const result = await listWorkItems('/repo', 'opened', 1, 20)
@@ -142,7 +165,7 @@ describe('gitlab client — combined listWorkItems', () => {
})
it('still returns issues when MRs error out', async () => {
glabExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 500'))
glabApiWithHeadersMock.mockRejectedValueOnce(new Error('HTTP 500'))
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{ id: 200, iid: 9, title: 'live issue', state: 'opened', updated_at: '2026-05-08' }
+268 -125
View File
@@ -16,10 +16,14 @@ import type {
import { derivePipelineStatus, mapIssueToWorkItem, mapMRInfo, mapMRToWorkItem } from './mappers'
import {
acquire,
classifyGlabError,
classifyListIssuesError,
getGlabKnownHosts,
getProjectRef,
getProjectRefForRemote,
glabHostnameArgs,
glabRepoExecOptions,
glabApiWithHeaders,
glabExecFileAsync,
release,
resolveIssueSource,
@@ -33,25 +37,6 @@ function encodedProject(projectPath: string): string {
return encodeURIComponent(projectPath)
}
function projectRefToGlabRepo(projectRef: ProjectRef): string {
// Why: `glab mr list` otherwise infers from cwd and can ignore an
// upstream/origin preference. A full URL also works for self-hosted hosts.
return `https://${projectRef.host}/${projectRef.path}`
}
function mrListStateFlags(state: MRListState): string[] {
switch (state) {
case 'opened':
return []
case 'merged':
return ['--merged']
case 'closed':
return ['--closed']
case 'all':
return ['--all']
}
}
/**
* Get the authenticated GitLab viewer. Mirrors getAuthenticatedViewer
* from the GitHub client — returns null when glab is unavailable, the
@@ -80,9 +65,12 @@ export async function getAuthenticatedViewer(): Promise<GitLabViewer | null> {
* Resolve a project's full GitLab project ref (host + path). Mirrors
* github/getRepoSlug. Returns null for non-GitLab remotes.
*/
export async function getProjectSlug(repoPath: string): Promise<ProjectRef | null> {
export async function getProjectSlug(
repoPath: string,
connectionId?: string | null
): Promise<ProjectRef | null> {
const knownHosts = await getGlabKnownHosts()
return getProjectRef(repoPath, knownHosts)
return getProjectRef(repoPath, knownHosts, connectionId)
}
/**
@@ -90,15 +78,23 @@ export async function getProjectSlug(repoPath: string): Promise<ProjectRef | nul
* Returns null when the MR doesn't exist or glab fails — callers
* decide whether to surface "not found" UI.
*/
export async function getMergeRequest(repoPath: string, iid: number): Promise<MRInfo | null> {
export async function getMergeRequest(
repoPath: string,
iid: number,
connectionId?: string | null
): Promise<MRInfo | null> {
const knownHosts = await getGlabKnownHosts()
const projectRef = await getProjectRef(repoPath, knownHosts)
const projectRef = await getProjectRef(repoPath, knownHosts, connectionId)
await acquire()
try {
const args = projectRef
? ['api', `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`]
? [
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`
]
: ['mr', 'view', String(iid), '--output', 'json']
const { stdout } = await glabExecFileAsync(args, { cwd: repoPath })
const { stdout } = await glabExecFileAsync(args, glabRepoExecOptions(repoPath, connectionId))
const data = JSON.parse(stdout) as Parameters<typeof mapMRInfo>[0] & {
head_pipeline?: { status?: string } | null
pipeline?: { status?: string } | null
@@ -125,14 +121,15 @@ export async function getMergeRequest(repoPath: string, iid: number): Promise<MR
export async function getMergeRequestForBranch(
repoPath: string,
branch: string,
linkedMRIid?: number | null
linkedMRIid?: number | null,
connectionId?: string | null
): Promise<MRInfo | null> {
const branchName = branch.replace(/^refs\/heads\//, '')
if (!branchName && linkedMRIid == null) {
return null
}
const knownHosts = await getGlabKnownHosts()
const projectRef = await getProjectRef(repoPath, knownHosts)
const projectRef = await getProjectRef(repoPath, knownHosts, connectionId)
if (!projectRef) {
return null
}
@@ -142,9 +139,10 @@ export async function getMergeRequestForBranch(
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/merge_requests?source_branch=${encodeURIComponent(branchName)}&order_by=updated_at&sort=desc&per_page=1`
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout) as (Parameters<typeof mapMRInfo>[0] & {
head_pipeline?: { status?: string } | null
@@ -162,8 +160,12 @@ export async function getMergeRequestForBranch(
// than the MR source branch. Fall back to the durable linked iid so the
// core review status still follows the workspace.
const { stdout } = await glabExecFileAsync(
['api', `projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}`],
{ cwd: repoPath }
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}`
],
glabRepoExecOptions(repoPath, connectionId)
)
const raw = JSON.parse(stdout) as Parameters<typeof mapMRInfo>[0] & {
head_pipeline?: { status?: string } | null
@@ -187,80 +189,60 @@ export async function listMergeRequests(
state: MRListState = 'opened',
page = 1,
perPage = 20,
preference?: IssueSourcePreference
preference?: IssueSourcePreference,
query?: string,
connectionId?: string | null
): Promise<ListMergeRequestsResult> {
const knownHosts = await getGlabKnownHosts()
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
// Why: MRs sit on `origin` in the fork model (the user's fork is where
// they push branches and submit MRs). Mirror github's `getOwnerRepo`
// call site by going through the upstream/origin preference resolver
// so cross-fork workflows reuse the same plumbing.
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
knownHosts,
connectionId
)
if (!projectRef) {
return {
items: [],
page,
perPage,
totalCount: 0,
totalPages: 0,
error: {
type: 'not_found',
message: 'No GitLab project found for this repository.'
}
}
}
// Why: 'all' is exposed as the picker filter but GitLab's API expects
// no state param to mean "any state". Drop the param when 'all'.
const stateParam = state === 'all' ? '' : `&state=${state}`
const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : ''
const path =
`projects/${encodedProject(projectRef.path)}/merge_requests?` +
`page=${page}&per_page=${perPage}&order_by=updated_at&sort=desc&with_merge_status_recheck=false${stateParam}${searchParam}`
const repoId = projectRef.path
await acquire()
try {
if (projectRef) {
// Why: use `glab mr list` (CLI) instead of the REST API directly.
// The CLI respects the user's glab auth configuration; `--repo`
// keeps upstream/origin preference resolution explicit.
const stateFlag = mrListStateFlags(state)
const { stdout } = await glabExecFileAsync(
[
'mr',
'list',
'--output',
'json',
'--per-page',
String(perPage),
'--page',
String(page),
'--order',
'updated_at',
'--sort',
'desc',
'--repo',
projectRefToGlabRepo(projectRef),
...stateFlag
],
{ cwd: repoPath }
)
const data = JSON.parse(stdout) as Parameters<typeof mapMRToWorkItem>[0][]
return {
items: data.map((d) => mapMRToWorkItem(d, projectRef.path)),
page,
perPage,
// Why: the CLI doesn't return x-total headers, so totals are
// approximate. For the Tasks UI this is acceptable — pagination
// still works via page+per_page.
totalCount: data.length,
totalPages: data.length < perPage ? page : page + 1
}
}
// Fallback — let glab infer project from cwd. This path is taken when
// the repo's remote host is not in getGlabKnownHosts() (e.g. a fresh
// self-hosted instance), but glab itself can still resolve it from the
// local git config.
const stateFlag = mrListStateFlags(state)
const { stdout } = await glabExecFileAsync(
[
'mr',
'list',
'--output',
'json',
'--per-page',
String(perPage),
'--page',
String(page),
'--order',
'updated_at',
'--sort',
'desc',
...stateFlag
],
{ cwd: repoPath }
const { body, headers } = await glabApiWithHeaders(
[...glabHostnameArgs(projectRef, connectionId), path],
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout) as Parameters<typeof mapMRToWorkItem>[0][]
const data = JSON.parse(body) as Parameters<typeof mapMRToWorkItem>[0][]
return {
items: data.map((d) => mapMRToWorkItem(d, 'unknown')),
items: data.map((d) => mapMRToWorkItem(d, repoId, projectRef)),
page,
perPage,
totalCount: data.length,
totalPages: data.length < perPage ? page : page + 1
totalCount: parseHeaderInt(headers['x-total'], 0),
// Why: when 'all' state is requested or the per_page is large,
// GitLab may not include x-total-pages; fall back to ceil(total/perPage).
totalPages:
parseHeaderInt(headers['x-total-pages'], 0) ||
Math.max(1, Math.ceil(parseHeaderInt(headers['x-total'], 0) / perPage))
}
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
@@ -277,6 +259,14 @@ export async function listMergeRequests(
}
}
function parseHeaderInt(value: string | undefined, fallback: number): number {
if (!value) {
return fallback
}
const parsed = Number.parseInt(value, 10)
return Number.isFinite(parsed) ? parsed : fallback
}
/**
* Fetch a work item (MR or issue) given an explicit project ref +
* iid + type. Mirrors github/getWorkItemByOwnerRepo — used by the
@@ -287,20 +277,25 @@ export async function getWorkItemByProjectRef(
repoPath: string,
projectRef: ProjectRef,
iid: number,
type: 'issue' | 'mr'
type: 'issue' | 'mr',
connectionId?: string | null
): Promise<GitLabWorkItem | null> {
await acquire()
try {
const resource = type === 'mr' ? 'merge_requests' : 'issues'
const { stdout } = await glabExecFileAsync(
['api', `projects/${encodedProject(projectRef.path)}/${resource}/${iid}`],
{ cwd: repoPath }
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/${resource}/${iid}`
],
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout)
if (type === 'mr') {
return mapMRToWorkItem(data, projectRef.path)
return mapMRToWorkItem(data, projectRef.path, projectRef)
}
return mapIssueToWorkItem(data, projectRef.path)
return mapIssueToWorkItem(data, projectRef.path, projectRef)
} catch {
return null
} finally {
@@ -337,11 +332,18 @@ export async function listWorkItems(
state: MRListState = 'opened',
page = 1,
perPage = 20,
preference?: IssueSourcePreference
preference?: IssueSourcePreference,
query?: string,
connectionId?: string | null
): Promise<GitLabPagedResult<GitLabWorkItem>> {
const issueState = mrStateToIssueState(state)
const knownHosts = await getGlabKnownHosts()
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
knownHosts,
connectionId
)
if (!projectRef) {
return {
items: [],
@@ -366,13 +368,13 @@ export async function listWorkItems(
// raw issues API directly and run mapIssueToWorkItem against the
// raw payload instead.
const [mrs, issues] = await Promise.all([
listMergeRequests(repoPath, state, page, perPage, preference),
listMergeRequests(repoPath, state, page, perPage, preference, query, connectionId),
issueState === null
? Promise.resolve({
items: [] as GitLabWorkItem[],
error: undefined as ClassifiedError | undefined
})
: fetchIssuesAsWorkItems(repoPath, projectRef, issueState, perPage)
: fetchIssuesAsWorkItems(repoPath, projectRef, issueState, perPage, query, connectionId)
])
const merged = [...mrs.items, ...issues.items].sort((a, b) =>
(b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')
@@ -401,21 +403,25 @@ export async function fetchIssuesAsWorkItems(
repoPath: string,
projectRef: ProjectRef,
state: IssueListState,
perPage: number
perPage: number,
query?: string,
connectionId?: string | null
): Promise<{ items: GitLabWorkItem[]; error: ClassifiedError | undefined }> {
await acquire()
try {
const stateParam = state === 'all' ? '' : `&state=${state}`
const searchParam = query?.trim() ? `&search=${encodeURIComponent(query.trim())}` : ''
const { stdout } = await glabExecFileAsync(
[
'api',
`projects/${encodedProject(projectRef.path)}/issues?per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}`
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/issues?per_page=${perPage}&order_by=updated_at&sort=desc${stateParam}${searchParam}`
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout) as Parameters<typeof mapIssueToWorkItem>[0][]
return {
items: data.map((d) => mapIssueToWorkItem(d, projectRef.path)),
items: data.map((d) => mapIssueToWorkItem(d, projectRef.path, projectRef)),
error: undefined
}
} catch (err) {
@@ -439,15 +445,27 @@ export async function fetchIssuesAsWorkItems(
* work directly from a mention/assignment without going to gitlab.com
* first.
*/
export async function listTodos(repoPath: string): Promise<GitLabTodo[]> {
export async function listTodos(
repoPath: string,
connectionId?: string | null
): Promise<GitLabTodo[]> {
const projectRef = await getProjectRef(repoPath, await getGlabKnownHosts(), connectionId)
if (connectionId && !projectRef) {
return []
}
await acquire()
try {
// Why: per_page=50 keeps the first-page round-trip small. Pagination
// is left for a follow-up — most users have <50 pending todos in
// practice and the UI shows the highest-priority ones first.
const { stdout } = await glabExecFileAsync(
['api', '--paginate', 'todos?state=pending&per_page=50'],
{ cwd: repoPath }
[
'api',
...(projectRef ? glabHostnameArgs(projectRef, connectionId) : []),
'--paginate',
'todos?state=pending&per_page=50'
],
glabRepoExecOptions(repoPath, connectionId)
)
type RESTTodo = {
id?: number
@@ -498,11 +516,15 @@ export async function listTodos(repoPath: string): Promise<GitLabTodo[]> {
async function withProjectRef<T>(
repoPath: string,
preference: IssueSourcePreference | undefined,
connectionId: string | null | undefined,
explicitProjectRef: ProjectRef | null | undefined,
fn: (projectRef: ProjectRef, repoFlag: string) => Promise<T>,
fallback: T
): Promise<T> {
const knownHosts = await getGlabKnownHosts()
const projectRef = await getProjectRef(repoPath, knownHosts)
const projectRef =
explicitProjectRef ??
(await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source
if (!projectRef) {
return fallback
}
@@ -511,14 +533,30 @@ async function withProjectRef<T>(
export async function closeMR(
repoPath: string,
iid: number
iid: number,
preference?: IssueSourcePreference,
connectionId?: string | null,
projectRef?: ProjectRef | null
): Promise<{ ok: true } | { ok: false; error: string }> {
return withProjectRef<{ ok: true } | { ok: false; error: string }>(
repoPath,
async (_pr, repoFlag) => {
preference,
connectionId,
projectRef,
async (projectRef, repoFlag) => {
await acquire()
try {
await glabExecFileAsync(['mr', 'close', String(iid), '-R', repoFlag], { cwd: repoPath })
await glabExecFileAsync(
[
'mr',
'close',
String(iid),
'-R',
repoFlag,
...glabHostnameArgs(projectRef, connectionId)
],
glabRepoExecOptions(repoPath, connectionId)
)
return { ok: true }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
@@ -539,14 +577,30 @@ export async function closeMR(
export async function reopenMR(
repoPath: string,
iid: number
iid: number,
preference?: IssueSourcePreference,
connectionId?: string | null,
projectRef?: ProjectRef | null
): Promise<{ ok: true } | { ok: false; error: string }> {
return withProjectRef<{ ok: true } | { ok: false; error: string }>(
repoPath,
async (_pr, repoFlag) => {
preference,
connectionId,
projectRef,
async (projectRef, repoFlag) => {
await acquire()
try {
await glabExecFileAsync(['mr', 'reopen', String(iid), '-R', repoFlag], { cwd: repoPath })
await glabExecFileAsync(
[
'mr',
'reopen',
String(iid),
'-R',
repoFlag,
...glabHostnameArgs(projectRef, connectionId)
],
glabRepoExecOptions(repoPath, connectionId)
)
return { ok: true }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
@@ -565,11 +619,17 @@ export async function reopenMR(
export async function mergeMR(
repoPath: string,
iid: number,
method: 'merge' | 'squash' | 'rebase' = 'merge'
method: 'merge' | 'squash' | 'rebase' = 'merge',
preference?: IssueSourcePreference,
connectionId?: string | null,
projectRef?: ProjectRef | null
): Promise<{ ok: true } | { ok: false; error: string }> {
return withProjectRef<{ ok: true } | { ok: false; error: string }>(
repoPath,
async (_pr, repoFlag) => {
preference,
connectionId,
projectRef,
async (projectRef, repoFlag) => {
await acquire()
try {
// Why: glab mr merge accepts --squash and --rebase flags;
@@ -578,8 +638,17 @@ export async function mergeMR(
const methodFlag =
method === 'squash' ? ['--squash'] : method === 'rebase' ? ['--rebase'] : []
await glabExecFileAsync(
['mr', 'merge', String(iid), '-R', repoFlag, '--yes', ...methodFlag],
{ cwd: repoPath }
[
'mr',
'merge',
String(iid),
'-R',
repoFlag,
'--yes',
...methodFlag,
...glabHostnameArgs(projectRef, connectionId)
],
glabRepoExecOptions(repoPath, connectionId)
)
return { ok: true }
} catch (err) {
@@ -595,23 +664,30 @@ export async function mergeMR(
export async function addMRComment(
repoPath: string,
iid: number,
body: string
body: string,
preference?: IssueSourcePreference,
connectionId?: string | null,
projectRef?: ProjectRef | null
): Promise<{ ok: true; comment: MRComment } | { ok: false; error: string }> {
return withProjectRef<{ ok: true; comment: MRComment } | { ok: false; error: string }>(
repoPath,
preference,
connectionId,
projectRef,
async (projectRef) => {
await acquire()
try {
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'-X',
'POST',
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}/notes`,
'-f',
`body=${body}`
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout) as {
id?: number
@@ -641,6 +717,73 @@ export async function addMRComment(
)
}
export async function updateMR(
repoPath: string,
iid: number,
updates: {
title?: string
body?: string
addLabels?: string[]
removeLabels?: string[]
},
preference?: IssueSourcePreference,
connectionId?: string | null,
projectRef?: ProjectRef | null
): Promise<{ ok: true } | { ok: false; error: string }> {
return withProjectRef<{ ok: true } | { ok: false; error: string }>(
repoPath,
preference,
connectionId,
projectRef,
async (projectRef) => {
const fields: string[] = []
const title = updates.title?.trim()
if (updates.title !== undefined) {
if (!title) {
return { ok: false, error: 'Title is required' }
}
fields.push(`title=${title}`)
}
if (updates.body !== undefined) {
fields.push(`description=${updates.body}`)
}
const addLabels = (updates.addLabels ?? []).filter((label) => label.trim().length > 0)
const removeLabels = (updates.removeLabels ?? []).filter((label) => label.trim().length > 0)
if (addLabels.length > 0) {
fields.push(`add_labels=${addLabels.join(',')}`)
}
if (removeLabels.length > 0) {
fields.push(`remove_labels=${removeLabels.join(',')}`)
}
if (fields.length === 0) {
return { ok: true }
}
await acquire()
try {
await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'-X',
'PUT',
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`,
...fields.flatMap((field) => ['-f', field])
],
glabRepoExecOptions(repoPath, connectionId)
)
return { ok: true }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return { ok: false, error: classifyGlabError(msg).message }
} finally {
release()
}
},
{ ok: false, error: 'Could not resolve GitLab project for this repository' }
)
}
/** Re-export so callers don't need to know the gl-utils module split. */
export { _resetProjectRefCache } from './gl-utils'
export {
+54 -3
View File
@@ -1,8 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
/* eslint-disable max-lines -- Why: GitLab remote parsing coverage needs many URL/host fixtures against the same mocked git/glab helpers. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, glabExecFileAsyncMock } = vi.hoisted(() => ({
const { gitExecFileAsyncMock, glabExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
glabExecFileAsyncMock: vi.fn()
glabExecFileAsyncMock: vi.fn(),
sshExecMock: vi.fn()
}))
vi.mock('../git/runner', () => ({
@@ -18,11 +20,13 @@ import {
getIssueProjectRef,
getGlabKnownHosts,
getProjectRef,
getProjectRefForRemote,
parseGitLabProjectRef,
parseGlabApiResponse,
parseGlabAuthStatusHosts,
resolveIssueSource
} from './gl-utils'
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
describe('gitlab project ref parsing', () => {
it('parses HTTPS and SSH GitLab.com remotes', () => {
@@ -99,9 +103,15 @@ describe('gitlab project ref parsing', () => {
describe('gitlab project ref resolution', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
sshExecMock.mockReset()
unregisterSshGitProvider('conn-1')
_resetProjectRefCache()
})
afterEach(() => {
unregisterSshGitProvider('conn-1')
})
it('keeps getProjectRef origin-based', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@gitlab.com:fork/orca.git\n'
@@ -155,6 +165,47 @@ describe('gitlab project ref resolution', () => {
path: 'stablyai/orca'
})
})
it('resolves project refs through the SSH git provider for connected repos', async () => {
sshExecMock.mockResolvedValueOnce({ stdout: 'git@gitlab.com:remote/orca.git\n', stderr: '' })
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({
host: 'gitlab.com',
path: 'remote/orca'
})
expect(sshExecMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], '/repo')
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
})
it('does not cache a missing SSH provider as a permanent null project ref', async () => {
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toBeNull()
sshExecMock.mockResolvedValueOnce({
stdout: 'git@gitlab.com:remote/orca.git\n',
stderr: ''
})
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({
host: 'gitlab.com',
path: 'remote/orca'
})
})
it('does not cache transient SSH exec failures as permanent null project refs', async () => {
sshExecMock
.mockRejectedValueOnce(new Error('ssh tunnel not ready'))
.mockResolvedValueOnce({ stdout: 'git@gitlab.com:remote/orca.git\n', stderr: '' })
registerSshGitProvider('conn-1', { exec: sshExecMock } as never)
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toBeNull()
await expect(getProjectRefForRemote('/repo', 'origin', undefined, 'conn-1')).resolves.toEqual({
host: 'gitlab.com',
path: 'remote/orca'
})
})
})
describe('resolveIssueSource', () => {
+47 -15
View File
@@ -2,6 +2,7 @@ import { execFile } from 'child_process'
import { promisify } from 'util'
import { gitExecFileAsync, glabExecFileAsync } from '../git/runner'
import type { ClassifiedError, GitLabProjectRef, IssueSourcePreference } from '../../shared/types'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
// Why: legacy generic execFile wrapper — only used by callers that don't need
// WSL-aware routing. Repo-scoped callers should use glabExecFileAsync from
@@ -167,22 +168,34 @@ export function parseGitLabProjectRef(
export async function getProjectRefForRemote(
repoPath: string,
remoteName: string,
knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS
knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS,
connectionId?: string | null
): Promise<ProjectRef | null> {
const cacheKey = `${repoPath}\0${remoteName}\0${knownHosts.join(',')}`
const cacheKey = `${connectionId ?? 'local'}\0${repoPath}\0${remoteName}\0${knownHosts.join(',')}`
if (projectRefCache.has(cacheKey)) {
return projectRefCache.get(cacheKey)!
}
try {
const { stdout } = await gitExecFileAsync(['remote', 'get-url', remoteName], {
cwd: repoPath
})
const sshGitProvider = connectionId ? getSshGitProvider(connectionId) : null
if (connectionId && !sshGitProvider) {
// Why: mobile can attempt GitLab loads before the SSH tunnel is ready.
// Caching that transient state would poison later loads after connect.
return null
}
const { stdout } = sshGitProvider
? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath)
: await gitExecFileAsync(['remote', 'get-url', remoteName], { cwd: repoPath })
const result = parseGitLabProjectRef(stdout, knownHosts)
if (result) {
projectRefCache.set(cacheKey, result)
return result
}
} catch {
if (connectionId) {
// Why: remote SSH failures are often transient tunnel/process errors.
// Do not cache them as "not a GitLab repo" for the rest of the session.
return null
}
// ignore — non-GitLab remote or no remote configured
}
projectRefCache.set(cacheKey, null)
@@ -191,20 +204,22 @@ export async function getProjectRefForRemote(
export async function getProjectRef(
repoPath: string,
knownHosts?: readonly string[]
knownHosts?: readonly string[],
connectionId?: string | null
): Promise<ProjectRef | null> {
return getProjectRefForRemote(repoPath, 'origin', knownHosts)
return getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId)
}
export async function getIssueProjectRef(
repoPath: string,
knownHosts?: readonly string[]
knownHosts?: readonly string[],
connectionId?: string | null
): Promise<ProjectRef | null> {
const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts)
const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts, connectionId)
if (upstream) {
return upstream
}
return getProjectRefForRemote(repoPath, 'origin', knownHosts)
return getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId)
}
export type ResolvedIssueSource = {
@@ -222,23 +237,40 @@ export type ResolvedIssueSource = {
export async function resolveIssueSource(
repoPath: string,
preference: IssueSourcePreference | undefined,
knownHosts?: readonly string[]
knownHosts?: readonly string[],
connectionId?: string | null
): Promise<ResolvedIssueSource> {
if (preference === 'upstream') {
const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts)
const upstream = await getProjectRefForRemote(repoPath, 'upstream', knownHosts, connectionId)
if (upstream) {
return { source: upstream, fellBack: false }
}
const origin = await getProjectRefForRemote(repoPath, 'origin', knownHosts)
const origin = await getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId)
return { source: origin, fellBack: origin !== null }
}
if (preference === 'origin') {
return {
source: await getProjectRefForRemote(repoPath, 'origin', knownHosts),
source: await getProjectRefForRemote(repoPath, 'origin', knownHosts, connectionId),
fellBack: false
}
}
return { source: await getIssueProjectRef(repoPath, knownHosts), fellBack: false }
return { source: await getIssueProjectRef(repoPath, knownHosts, connectionId), fellBack: false }
}
export function glabRepoExecOptions(
repoPath: string,
connectionId?: string | null
): { cwd?: string } {
return connectionId ? {} : { cwd: repoPath }
}
export function glabHostnameArgs(
projectRef: Pick<ProjectRef, 'host'> | null | undefined,
connectionId?: string | null
): string[] {
// Why: local glab commands can infer host from cwd; SSH-backed calls have
// no local cwd, so self-hosted instances need an explicit hostname.
return connectionId && projectRef?.host ? ['--hostname', projectRef.host] : []
}
// ── Known-hosts discovery via `glab auth status` ────────────────────
+92 -1
View File
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: GitLab issue mutation/list coverage shares glab mocks across related endpoint cases. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GlUtils from './gl-utils'
@@ -30,7 +31,15 @@ vi.mock('./gl-utils', async () => {
}
})
import { addIssueComment, createIssue, getIssue, listIssues, updateIssue } from './issues'
import {
addIssueComment,
createIssue,
getIssue,
listAssignableUsers,
listIssues,
listLabels,
updateIssue
} from './issues'
describe('gitlab issue operations', () => {
beforeEach(() => {
@@ -226,6 +235,62 @@ describe('gitlab issue operations', () => {
)
})
it('updateIssue applies body edits via the issue API', async () => {
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
await expect(updateIssue('/repo-root', 5, { body: 'Updated body' })).resolves.toEqual({
ok: true
})
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
['api', '-X', 'PUT', 'projects/stablyai%2Forca/issues/5', '-f', 'description=Updated body'],
{ cwd: '/repo-root' }
)
})
it('routes issue metadata reads through the selected SSH GitLab host', async () => {
getIssueProjectRefMock
.mockResolvedValueOnce({ host: 'git.internal', path: 'stablyai/orca' })
.mockResolvedValueOnce({ host: 'git.internal', path: 'stablyai/orca' })
glabExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'bug\nfeature\n' })
.mockResolvedValueOnce({
stdout: '{"username":"alice","name":"Alice","avatar_url":"https://example.com/a.png"}\n'
})
await expect(listLabels('/repo-root', 'upstream', 'conn-1')).resolves.toEqual([
'bug',
'feature'
])
await expect(listAssignableUsers('/repo-root', 'upstream', 'conn-1')).resolves.toEqual([
{
username: 'alice',
name: 'Alice',
avatarUrl: 'https://example.com/a.png'
}
])
expect(glabExecFileAsyncMock.mock.calls[0][0]).toEqual([
'api',
'--hostname',
'git.internal',
'--paginate',
'projects/stablyai%2Forca/labels',
'--jq',
'.[].name'
])
expect(glabExecFileAsyncMock.mock.calls[1][0]).toEqual([
'api',
'--hostname',
'git.internal',
'--paginate',
'projects/stablyai%2Forca/members/all?per_page=100',
'--jq',
'.[] | {username, name, avatar_url}'
])
})
it('addIssueComment posts to /notes and maps the response', async () => {
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
glabExecFileAsyncMock.mockResolvedValueOnce({
@@ -256,6 +321,32 @@ describe('gitlab issue operations', () => {
)
})
it('addIssueComment passes hostname for SSH-backed self-hosted repos', async () => {
getIssueProjectRefMock.mockResolvedValueOnce({
host: 'gitlab.example.com',
path: 'stablyai/orca'
})
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({ id: 100, body: 'Hello' })
})
await addIssueComment('/repo-root', 5, 'Hello', undefined, 'conn-1')
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
[
'api',
'--hostname',
'gitlab.example.com',
'-X',
'POST',
'projects/stablyai%2Forca/issues/5/notes',
'-f',
'body=Hello'
],
{}
)
})
it('returns null from getIssue when project ref cannot be resolved', async () => {
getIssueProjectRefMock.mockResolvedValueOnce(null)
// Why: when there's no GitLab project ref the fallback path
+114 -36
View File
@@ -13,7 +13,7 @@ import type {
} from '../../shared/types'
import { mapGitLabIssueInfo } from './mappers'
// prettier-ignore
import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListIssuesError, getGlabKnownHosts } from './gl-utils'
import { glabExecFileAsync, acquire, release, getIssueProjectRef, resolveIssueSource, classifyGlabError, classifyListIssuesError, getGlabKnownHosts, glabRepoExecOptions, glabHostnameArgs, type ProjectRef } from './gl-utils'
// Why: parallel to GitHub's IssueListResult — distinguishes a successful-
// empty listing from a failed fetch.
@@ -40,16 +40,21 @@ function encodedProject(projectPath: string): string {
*/
export async function getIssue(
repoPath: string,
issueNumber: number
issueNumber: number,
connectionId?: string | null
): Promise<GitLabIssueInfo | null> {
const knownHosts = await getGlabKnownHosts()
const projectRef = await getIssueProjectRef(repoPath, knownHosts)
const projectRef = await getIssueProjectRef(repoPath, knownHosts, connectionId)
await acquire()
try {
if (projectRef) {
const { stdout } = await glabExecFileAsync(
['api', `projects/${encodedProject(projectRef.path)}/issues/${issueNumber}`],
{ cwd: repoPath }
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/issues/${issueNumber}`
],
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout)
return mapGitLabIssueInfo(data)
@@ -57,7 +62,7 @@ export async function getIssue(
// Fallback for non-GitLab remotes — let glab infer the project from cwd.
const { stdout } = await glabExecFileAsync(
['issue', 'view', String(issueNumber), '--output', 'json'],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout)
return mapGitLabIssueInfo(data)
@@ -83,10 +88,16 @@ export async function listIssues(
limit = 20,
preference?: IssueSourcePreference,
state: IssueListState = 'opened',
assignee?: string
assignee?: string,
connectionId?: string | null
): Promise<IssueListResult> {
const knownHosts = await getGlabKnownHosts()
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
knownHosts,
connectionId
)
await acquire()
try {
if (projectRef) {
@@ -95,9 +106,10 @@ export async function listIssues(
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/issues?per_page=${limit}&order_by=updated_at&sort=desc${stateParam}${scopeParam}`
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout) as Record<string, unknown>[]
// Why: GitLab's project issues endpoint returns true issues only
@@ -126,7 +138,7 @@ export async function listIssues(
...stateFlag,
...assigneeFlag
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout) as unknown[]
return {
@@ -151,14 +163,20 @@ export async function createIssue(
repoPath: string,
title: string,
body: string,
preference?: IssueSourcePreference
preference?: IssueSourcePreference,
connectionId?: string | null
): Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> {
const trimmedTitle = title.trim()
if (!trimmedTitle) {
return { ok: false, error: 'Title is required' }
}
const knownHosts = await getGlabKnownHosts()
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
knownHosts,
connectionId
)
if (!projectRef) {
return {
ok: false,
@@ -170,6 +188,7 @@ export async function createIssue(
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'-X',
'POST',
`projects/${encodedProject(projectRef.path)}/issues`,
@@ -179,7 +198,7 @@ export async function createIssue(
// Why: GitLab uses `description` (not `body`) for issue text.
`description=${body}`
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout) as { iid?: number; web_url?: string; url?: string }
if (typeof data.iid !== 'number') {
@@ -201,19 +220,21 @@ export async function createIssue(
/**
* Update an existing GitLab issue.
*
* Why this path doesn't take a preference — mirrors github/updateIssue:
* mutations target an issue number already bound to a worktree / linked
* elsewhere. Routing through the live per-repo preference would let a
* user open upstream#N, toggle selector to origin, save, and silently
* write to a different project's issue with the same iid.
* Why: callers that list through a per-repo issue source preference must
* mutate the same GitLab project, or identical IIDs on origin/upstream can
* silently edit the wrong issue.
*/
export async function updateIssue(
repoPath: string,
issueNumber: number,
updates: GitLabIssueUpdate
updates: GitLabIssueUpdate,
preference?: IssueSourcePreference,
connectionId?: string | null,
projectRefOverride?: ProjectRef | null
): Promise<{ ok: true } | { ok: false; error: string }> {
const knownHosts = await getGlabKnownHosts()
const projectRef = await getIssueProjectRef(repoPath, knownHosts)
const projectRef =
projectRefOverride ??
(await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source
if (!projectRef) {
return {
ok: false,
@@ -229,9 +250,17 @@ export async function updateIssue(
await acquire()
try {
const cmd = updates.state === 'closed' ? 'close' : 'reopen'
await glabExecFileAsync(['issue', cmd, String(issueNumber), '-R', repoFlag], {
cwd: repoPath
})
await glabExecFileAsync(
[
'issue',
cmd,
String(issueNumber),
'-R',
repoFlag,
...glabHostnameArgs(projectRef, connectionId)
],
glabRepoExecOptions(repoPath, connectionId)
)
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
// Treat "already closed/reopened" as a no-op (matches gh path).
@@ -243,8 +272,38 @@ export async function updateIssue(
}
}
if (updates.body !== undefined) {
await acquire()
try {
await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'-X',
'PUT',
`projects/${encodedProject(repoFlag)}/issues/${issueNumber}`,
'-f',
`description=${updates.body}`
],
glabRepoExecOptions(repoPath, connectionId)
)
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
errors.push(classifyGlabError(stderr).message)
} finally {
release()
}
}
// Field edits via `glab issue update`.
const editArgs: string[] = ['issue', 'update', String(issueNumber), '-R', repoFlag]
const editArgs: string[] = [
'issue',
'update',
String(issueNumber),
'-R',
repoFlag,
...glabHostnameArgs(projectRef, connectionId)
]
let hasEditArgs = false
if (updates.title) {
@@ -271,7 +330,7 @@ export async function updateIssue(
if (hasEditArgs) {
await acquire()
try {
await glabExecFileAsync(editArgs, { cwd: repoPath })
await glabExecFileAsync(editArgs, glabRepoExecOptions(repoPath, connectionId))
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
errors.push(classifyGlabError(stderr).message)
@@ -293,10 +352,14 @@ export async function updateIssue(
export async function addIssueComment(
repoPath: string,
issueNumber: number,
body: string
body: string,
preference?: IssueSourcePreference,
connectionId?: string | null,
projectRefOverride?: ProjectRef | null
): Promise<GitLabCommentResult> {
const knownHosts = await getGlabKnownHosts()
const projectRef = await getIssueProjectRef(repoPath, knownHosts)
const projectRef =
projectRefOverride ??
(await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source
if (!projectRef) {
return {
ok: false,
@@ -308,13 +371,14 @@ export async function addIssueComment(
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'-X',
'POST',
`projects/${encodedProject(projectRef.path)}/issues/${issueNumber}/notes`,
'-f',
`body=${body}`
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout) as {
id?: number
@@ -345,10 +409,16 @@ export async function addIssueComment(
export async function listLabels(
repoPath: string,
preference?: IssueSourcePreference
preference?: IssueSourcePreference,
connectionId?: string | null
): Promise<string[]> {
const knownHosts = await getGlabKnownHosts()
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
knownHosts,
connectionId
)
if (!projectRef) {
return []
}
@@ -357,12 +427,13 @@ export async function listLabels(
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'--paginate',
`projects/${encodedProject(projectRef.path)}/labels`,
'--jq',
'.[].name'
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
return stdout
.trim()
@@ -377,10 +448,16 @@ export async function listLabels(
export async function listAssignableUsers(
repoPath: string,
preference?: IssueSourcePreference
preference?: IssueSourcePreference,
connectionId?: string | null
): Promise<GitLabAssignableUser[]> {
const knownHosts = await getGlabKnownHosts()
const { source: projectRef } = await resolveIssueSource(repoPath, preference, knownHosts)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
knownHosts,
connectionId
)
if (!projectRef) {
return []
}
@@ -393,12 +470,13 @@ export async function listAssignableUsers(
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'--paginate',
`projects/${encodedProject(projectRef.path)}/members/all?per_page=100`,
'--jq',
'.[] | {username, name, avatar_url}'
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
type RESTMember = { username?: string; name?: string | null; avatar_url?: string | null }
const users: GitLabAssignableUser[] = []
+11 -4
View File
@@ -247,7 +247,11 @@ type GitLabMRRawForWorkItem = {
target_project_id?: number
}
export function mapMRToWorkItem(data: GitLabMRRawForWorkItem, repoId: string): GitLabWorkItem {
export function mapMRToWorkItem(
data: GitLabMRRawForWorkItem,
repoId: string,
projectRef?: GitLabWorkItem['projectRef']
): GitLabWorkItem {
const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name))
const number = data.iid ?? 0
return {
@@ -268,7 +272,8 @@ export function mapMRToWorkItem(data: GitLabMRRawForWorkItem, repoId: string): G
data.source_project_id !== undefined &&
data.target_project_id !== undefined &&
data.source_project_id !== data.target_project_id,
repoId
repoId,
...(projectRef ? { projectRef } : {})
}
}
@@ -286,7 +291,8 @@ type GitLabIssueRawForWorkItem = {
export function mapIssueToWorkItem(
data: GitLabIssueRawForWorkItem,
repoId: string
repoId: string,
projectRef?: GitLabWorkItem['projectRef']
): GitLabWorkItem {
const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name))
const number = data.iid ?? 0
@@ -303,7 +309,8 @@ export function mapIssueToWorkItem(
labels,
updatedAt: data.updated_at ?? '',
author: data.author?.username ?? null,
repoId
repoId,
...(projectRef ? { projectRef } : {})
}
}
+49 -82
View File
@@ -12,12 +12,14 @@ import { mapIssueToWorkItem, mapMRToWorkItem } from './mappers'
import {
acquire,
getGlabKnownHosts,
getIssueProjectRef,
getProjectRef,
glabHostnameArgs,
glabRepoExecOptions,
glabExecFileAsync,
release,
resolveIssueSource,
type ProjectRef
} from './gl-utils'
import type { IssueSourcePreference } from '../../shared/types'
function encodedProject(projectPath: string): string {
return encodeURIComponent(projectPath)
@@ -79,16 +81,18 @@ async function fetchDiscussions(
repoPath: string,
projectRef: ProjectRef,
type: 'issue' | 'mr',
iid: number
iid: number,
connectionId?: string | null
): Promise<GitLabRawDiscussion[]> {
const resource = type === 'mr' ? 'merge_requests' : 'issues'
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'--paginate',
`projects/${encodedProject(projectRef.path)}/${resource}/${iid}/discussions?per_page=100`
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
return JSON.parse(stdout) as GitLabRawDiscussion[]
}
@@ -118,15 +122,17 @@ function mapPipelineJob(raw: GitLabRawJob): GitLabPipelineJob {
async function fetchPipelineJobs(
repoPath: string,
projectRef: ProjectRef,
pipelineId: number
pipelineId: number,
connectionId?: string | null
): Promise<GitLabPipelineJob[]> {
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
'--paginate',
`projects/${encodedProject(projectRef.path)}/pipelines/${pipelineId}/jobs?per_page=100`
],
{ cwd: repoPath }
glabRepoExecOptions(repoPath, connectionId)
)
const data = JSON.parse(stdout) as GitLabRawJob[]
return data.map(mapPipelineJob)
@@ -157,33 +163,26 @@ type GitLabRawMR = Parameters<typeof mapMRToWorkItem>[0] & {
export async function getWorkItemDetails(
repoPath: string,
iid: number,
type: 'issue' | 'mr'
type: 'issue' | 'mr',
preference?: IssueSourcePreference,
connectionId?: string | null,
projectRefOverride?: ProjectRef | null
): Promise<GitLabWorkItemDetails | null> {
const knownHosts = await getGlabKnownHosts()
// Why: issues honor the upstream/origin preference (issues live on
// upstream when a fork is checked out). MRs always target origin —
// the fork model puts MRs against the project the user pushes to.
// Why: detail fetches must use the same project source as the list row
// that opened them, otherwise forked repos can show a row from one remote
// and a detail sheet from another.
const projectRef =
type === 'issue'
? await getIssueProjectRef(repoPath, knownHosts)
: await getProjectRef(repoPath, knownHosts)
projectRefOverride ??
(await resolveIssueSource(repoPath, preference, await getGlabKnownHosts(), connectionId)).source
if (!projectRef) {
return null
}
await acquire()
try {
if (projectRef) {
if (type === 'issue') {
return await fetchIssueDetails(repoPath, projectRef, iid)
}
return await fetchMRDetails(repoPath, projectRef, iid)
}
// Fallback — let glab infer project from cwd. This path is taken when
// the repo's remote host is not in getGlabKnownHosts() (e.g. a fresh
// self-hosted instance), but glab itself can still resolve it from the
// local git config.
if (type === 'issue') {
return await fetchIssueDetailsFallback(repoPath, iid)
return await fetchIssueDetails(repoPath, projectRef, iid, connectionId)
}
return await fetchMRDetailsFallback(repoPath, iid)
return await fetchMRDetails(repoPath, projectRef, iid, connectionId)
} catch {
return null
} finally {
@@ -194,19 +193,25 @@ export async function getWorkItemDetails(
async function fetchIssueDetails(
repoPath: string,
projectRef: ProjectRef,
iid: number
iid: number,
connectionId?: string | null
): Promise<GitLabWorkItemDetails | null> {
// Why: fan out the two reads. Issues don't have a pipeline so this
// pair covers everything the dialog renders.
const [issueRes, discussions] = await Promise.all([
glabExecFileAsync(['api', `projects/${encodedProject(projectRef.path)}/issues/${iid}`], {
cwd: repoPath
}),
fetchDiscussions(repoPath, projectRef, 'issue', iid)
glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/issues/${iid}`
],
glabRepoExecOptions(repoPath, connectionId)
),
fetchDiscussions(repoPath, projectRef, 'issue', iid, connectionId)
])
const issueRaw = JSON.parse(issueRes.stdout) as GitLabRawIssue
const item: Omit<GitLabWorkItem, 'repoId'> = (() => {
const full = mapIssueToWorkItem(issueRaw, projectRef.path)
const full = mapIssueToWorkItem(issueRaw, projectRef.path, projectRef)
// Why: omit repoId from the returned shape — the renderer stamps
// it from the dialog's caller (TaskPage / picker) so the main
// process doesn't need to know Orca's Repo.id.
@@ -223,54 +228,36 @@ async function fetchIssueDetails(
}
}
async function fetchIssueDetailsFallback(
repoPath: string,
iid: number
): Promise<GitLabWorkItemDetails | null> {
const { stdout } = await glabExecFileAsync(['issue', 'view', String(iid), '--output', 'json'], {
cwd: repoPath
})
const issueRaw = JSON.parse(stdout) as GitLabRawIssue
const item: Omit<GitLabWorkItem, 'repoId'> = (() => {
const full = mapIssueToWorkItem(issueRaw, 'unknown')
const { repoId: _repoId, ...rest } = full
return rest
})()
return {
item,
body: issueRaw.description ?? '',
comments: [],
assignees: (issueRaw.assignees ?? [])
.map((a) => a?.username)
.filter((u): u is string => typeof u === 'string')
}
}
async function fetchMRDetails(
repoPath: string,
projectRef: ProjectRef,
iid: number
iid: number,
connectionId?: string | null
): Promise<GitLabWorkItemDetails | null> {
// Why: MR detail + discussions in parallel. The pipeline jobs fetch
// depends on `head_pipeline.id` from the MR payload, so it has to
// wait — but it's a single follow-up call rather than a serial chain.
const [mrRes, discussions] = await Promise.all([
glabExecFileAsync(
['api', `projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`],
{ cwd: repoPath }
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/merge_requests/${iid}`
],
glabRepoExecOptions(repoPath, connectionId)
),
fetchDiscussions(repoPath, projectRef, 'mr', iid)
fetchDiscussions(repoPath, projectRef, 'mr', iid, connectionId)
])
const mrRaw = JSON.parse(mrRes.stdout) as GitLabRawMR
const item: Omit<GitLabWorkItem, 'repoId'> = (() => {
const full = mapMRToWorkItem(mrRaw, projectRef.path)
const full = mapMRToWorkItem(mrRaw, projectRef.path, projectRef)
const { repoId: _repoId, ...rest } = full
return rest
})()
const pipelineId = mrRaw.head_pipeline?.id
const pipelineJobs =
typeof pipelineId === 'number'
? await fetchPipelineJobs(repoPath, projectRef, pipelineId).catch(() => [])
? await fetchPipelineJobs(repoPath, projectRef, pipelineId, connectionId).catch(() => [])
: undefined
return {
item,
@@ -281,23 +268,3 @@ async function fetchMRDetails(
...(pipelineJobs !== undefined ? { pipelineJobs } : {})
}
}
async function fetchMRDetailsFallback(
repoPath: string,
iid: number
): Promise<GitLabWorkItemDetails | null> {
const { stdout } = await glabExecFileAsync(['mr', 'view', String(iid), '--output', 'json'], {
cwd: repoPath
})
const mrRaw = JSON.parse(stdout) as GitLabRawMR
const item: Omit<GitLabWorkItem, 'repoId'> = (() => {
const full = mapMRToWorkItem(mrRaw, 'unknown')
const { repoId: _repoId, ...rest } = full
return rest
})()
return {
item,
body: mrRaw.description ?? '',
comments: []
}
}
+88 -20
View File
@@ -65,6 +65,10 @@ function normalizeIssueAssignee(value: unknown): '@me' | undefined {
return value === '@me' ? '@me' : undefined
}
function repoConnectionId(repo: Repo): string | null {
return repo.connectionId ?? null
}
export function registerGitLabHandlers(store: Store): void {
ipcMain.handle('gitlab:viewer', async () => {
return getAuthenticatedViewer()
@@ -72,20 +76,25 @@ export function registerGitLabHandlers(store: Store): void {
ipcMain.handle('gitlab:projectSlug', async (_event, args: { repoPath: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return getProjectSlug(repo.path)
return getProjectSlug(repo.path, repoConnectionId(repo))
})
ipcMain.handle(
'gitlab:mrForBranch',
async (_event, args: { repoPath: string; branch: string; linkedMRIid?: number | null }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return getMergeRequestForBranch(repo.path, args.branch, args.linkedMRIid ?? null)
return getMergeRequestForBranch(
repo.path,
args.branch,
args.linkedMRIid ?? null,
repoConnectionId(repo)
)
}
)
ipcMain.handle('gitlab:mr', async (_event, args: { repoPath: string; iid: number }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return getMergeRequest(repo.path, args.iid)
return getMergeRequest(repo.path, args.iid, repoConnectionId(repo))
})
ipcMain.handle(
@@ -103,14 +112,21 @@ export function registerGitLabHandlers(store: Store): void {
const state = normalizeMRListState(args.state)
const page = normalizePositiveInteger(args.page, 1, 10_000)
const perPage = normalizePositiveInteger(args.perPage, 20, 100)
const result = await listMergeRequests(repo.path, state, page, perPage)
return result
return listMergeRequests(
repo.path,
state,
page,
perPage,
repo.issueSourcePreference,
undefined,
repoConnectionId(repo)
)
}
)
ipcMain.handle('gitlab:issue', async (_event, args: { repoPath: string; number: number }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return getIssue(repo.path, args.number)
return getIssue(repo.path, args.number, repoConnectionId(repo))
})
ipcMain.handle(
@@ -128,7 +144,14 @@ export function registerGitLabHandlers(store: Store): void {
const limit = normalizePositiveInteger(args.limit, 20, 100)
const state = normalizeIssueListState(args.state)
const assignee = normalizeIssueAssignee(args.assignee)
const result = await listIssues(repo.path, limit, undefined, state, assignee)
const result = await listIssues(
repo.path,
limit,
repo.issueSourcePreference,
state,
assignee,
repoConnectionId(repo)
)
// Why: Tasks page expects GitLabWorkItem[] so it can share row
// rendering with MRs. Map IssueInfo → WorkItem here so the renderer
// doesn't need a separate code path.
@@ -152,7 +175,13 @@ export function registerGitLabHandlers(store: Store): void {
'gitlab:createIssue',
async (_event, args: { repoPath: string; title: string; body: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return createIssue(repo.path, args.title, args.body)
return createIssue(
repo.path,
args.title,
args.body,
repo.issueSourcePreference,
repoConnectionId(repo)
)
}
)
@@ -160,7 +189,13 @@ export function registerGitLabHandlers(store: Store): void {
'gitlab:updateIssue',
async (_event, args: { repoPath: string; number: number; updates: GitLabIssueUpdate }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return updateIssue(repo.path, args.number, args.updates)
return updateIssue(
repo.path,
args.number,
args.updates,
repo.issueSourcePreference,
repoConnectionId(repo)
)
}
)
@@ -168,18 +203,24 @@ export function registerGitLabHandlers(store: Store): void {
'gitlab:addIssueComment',
async (_event, args: { repoPath: string; number: number; body: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return addIssueComment(repo.path, args.number, args.body)
return addIssueComment(
repo.path,
args.number,
args.body,
repo.issueSourcePreference,
repoConnectionId(repo)
)
}
)
ipcMain.handle('gitlab:listLabels', async (_event, args: { repoPath: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return listLabels(repo.path)
return listLabels(repo.path, repo.issueSourcePreference, repoConnectionId(repo))
})
ipcMain.handle('gitlab:listAssignableUsers', async (_event, args: { repoPath: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return listAssignableUsers(repo.path)
return listAssignableUsers(repo.path, repo.issueSourcePreference, repoConnectionId(repo))
})
// Why: combined MR + issue list — Tasks screen and any future picker
@@ -201,7 +242,10 @@ export function registerGitLabHandlers(store: Store): void {
repo.path,
normalizeMRListState(args.state),
normalizePositiveInteger(args.page, 1, 10_000),
normalizePositiveInteger(args.perPage, 20, 100)
normalizePositiveInteger(args.perPage, 20, 100),
repo.issueSourcePreference,
undefined,
repoConnectionId(repo)
)
}
)
@@ -212,18 +256,24 @@ export function registerGitLabHandlers(store: Store): void {
'gitlab:workItemDetails',
async (_event, args: { repoPath: string; iid: number; type: 'issue' | 'mr' }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return getWorkItemDetails(repo.path, args.iid, args.type)
return getWorkItemDetails(
repo.path,
args.iid,
args.type,
repo.issueSourcePreference,
repoConnectionId(repo)
)
}
)
ipcMain.handle('gitlab:closeMR', async (_event, args: { repoPath: string; iid: number }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return closeMR(repo.path, args.iid)
return closeMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo))
})
ipcMain.handle('gitlab:reopenMR', async (_event, args: { repoPath: string; iid: number }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return reopenMR(repo.path, args.iid)
return reopenMR(repo.path, args.iid, repo.issueSourcePreference, repoConnectionId(repo))
})
ipcMain.handle(
@@ -233,7 +283,13 @@ export function registerGitLabHandlers(store: Store): void {
args: { repoPath: string; iid: number; method?: 'merge' | 'squash' | 'rebase' }
) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return mergeMR(repo.path, args.iid, args.method ?? 'merge')
return mergeMR(
repo.path,
args.iid,
args.method ?? 'merge',
repo.issueSourcePreference,
repoConnectionId(repo)
)
}
)
@@ -241,7 +297,13 @@ export function registerGitLabHandlers(store: Store): void {
'gitlab:addMRComment',
async (_event, args: { repoPath: string; iid: number; body: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return addMRComment(repo.path, args.iid, args.body)
return addMRComment(
repo.path,
args.iid,
args.body,
repo.issueSourcePreference,
repoConnectionId(repo)
)
}
)
@@ -250,7 +312,7 @@ export function registerGitLabHandlers(store: Store): void {
// care about cwd because the endpoint is user-scoped.
ipcMain.handle('gitlab:todos', async (_event, args: { repoPath: string }) => {
const repo = assertRegisteredRepo(args.repoPath, store)
return listTodos(repo.path)
return listTodos(repo.path, repoConnectionId(repo))
})
// Why: paste-URL flow in the picker. The user pastes a GitLab URL that
@@ -271,7 +333,13 @@ export function registerGitLabHandlers(store: Store): void {
) => {
const repo = assertRegisteredRepo(args.repoPath, store)
const projectRef: ProjectRef = { host: args.host, path: args.path }
const result = await getWorkItemByProjectRef(repo.path, projectRef, args.iid, args.type)
const result = await getWorkItemByProjectRef(
repo.path,
projectRef,
args.iid,
args.type,
repoConnectionId(repo)
)
// Why: only persist a recent entry when the lookup actually
// produced an item. A 404 / auth failure shouldn't pollute the
// user's recents list with project paths they can't read.
+17 -29
View File
@@ -77,42 +77,30 @@ export function registerHostedReviewHandlers(store: Store, stats: StatsCollector
'hostedReview:getCreationEligibility',
async (_event, args: HostedReviewCreationEligibilityArgs) => {
const repo = assertRegisteredRepo(args.repoPath, store)
if (repo.connectionId) {
return {
provider: 'unsupported' as const,
review: null,
canCreate: false,
blockedReason: 'unsupported_provider' as const,
nextAction: null,
defaultBaseRef: args.base ?? null,
head: args.branch,
title: null,
body: null
}
}
const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath)
return getHostedReviewCreationEligibility({ ...args, repoPath: worktreePath })
return getHostedReviewCreationEligibility({
...args,
repoPath: worktreePath,
connectionId: repo.connectionId ?? null
})
}
)
ipcMain.handle('hostedReview:create', async (_event, args: CreateHostedReviewArgs) => {
const repo = assertRegisteredRepo(args.repoPath, store)
if (repo.connectionId) {
return {
ok: false as const,
code: 'unsupported_provider' as const,
error: 'Creating pull requests from SSH worktrees is not supported yet.'
}
}
const worktreePath = await resolveHostedReviewWorktreePath(repo, store, args.worktreePath)
const result = await createHostedReview(worktreePath, {
provider: args.provider,
base: args.base,
head: args.head,
title: args.title,
body: args.body,
draft: args.draft
})
const result = await createHostedReview(
worktreePath,
{
provider: args.provider,
base: args.base,
head: args.head,
title: args.title,
body: args.body,
draft: args.draft
},
repo.connectionId ?? null
)
if (result.ok && !stats.hasCountedPR(result.url)) {
stats.record({
type: 'pr_created',
+26 -6
View File
@@ -32,6 +32,19 @@ import type { OrcaRuntimeService } from '../runtime/orca-runtime'
let sshStore: SshConnectionStore | null = null
let connectionManager: SshConnectionManager | null = null
let portForwardManager: SshPortForwardManager | null = null
let registeredConnectSshTarget: ((targetId: string) => Promise<SshConnectionState>) | null = null
let registeredGetSshState: ((targetId: string) => SshConnectionState | undefined) | null = null
export async function connectRegisteredSshTarget(targetId: string): Promise<SshConnectionState> {
if (!registeredConnectSshTarget) {
throw new Error('ssh_handlers_not_registered')
}
return registeredConnectSshTarget(targetId)
}
export function getRegisteredSshState(targetId: string): SshConnectionState | undefined {
return registeredGetSshState?.(targetId)
}
// Why: one session per SSH target encapsulates the entire relay lifecycle
// (multiplexer, providers, abort controller, state machine). Eliminates the
@@ -370,8 +383,8 @@ export function registerSshHandlers(
// ── Connection lifecycle ───────────────────────────────────────────
ipcMain.handle('ssh:connect', async (_event, args: { targetId: string }) => {
const reset = resetRelayInFlight.get(args.targetId)
async function connectTarget(targetId: string): Promise<SshConnectionState> {
const reset = resetRelayInFlight.get(targetId)
if (reset) {
await reset
}
@@ -379,18 +392,25 @@ export function registerSshHandlers(
// Why: serialize concurrent ssh:connect calls for the same target.
// Multiple tabs can fire connect simultaneously; without this, they
// interleave and the first session leaks.
const existing = connectInFlight.get(args.targetId)
const existing = connectInFlight.get(targetId)
if (existing) {
return existing
}
const promise = doConnect(args.targetId)
connectInFlight.set(args.targetId, promise)
const promise = doConnect(targetId)
connectInFlight.set(targetId, promise)
try {
return await promise
} finally {
connectInFlight.delete(args.targetId)
connectInFlight.delete(targetId)
}
}
registeredConnectSshTarget = connectTarget
registeredGetSshState = (targetId: string) => getPublicSshState(targetId)
ipcMain.handle('ssh:connect', async (_event, args: { targetId: string }) => {
return connectTarget(args.targetId)
})
async function doConnect(targetId: string): Promise<SshConnectionState> {
+49 -7
View File
@@ -643,10 +643,6 @@ export async function createRemoteWorktree(
store: Store,
mainWindow: BrowserWindow
): Promise<CreateWorktreeResult> {
if (args.sparseCheckout) {
throw new Error('Sparse checkout is not supported for remote SSH repos yet.')
}
const provider = requireSshGitProvider(repo.connectionId!)
const settings = store.getSettings()
@@ -721,6 +717,31 @@ export async function createRemoteWorktree(
}
}
const sparseDirectories = args.sparseCheckout
? normalizeSparseDirectories(args.sparseCheckout.directories)
: []
if (args.sparseCheckout && sparseDirectories.length === 0) {
throw new Error('Sparse checkout requires at least one repo-relative directory.')
}
let sparsePresetId: string | undefined
if (args.sparseCheckout?.presetId) {
const preset = store
.getSparsePresets(repo.id)
.find((entry) => entry.id === args.sparseCheckout?.presetId)
if (preset?.repoId === repo.id) {
try {
const presetDirectories = normalizeSparseDirectories(preset.directories)
const presetSet = new Set(presetDirectories)
const directoriesMatch =
presetDirectories.length === sparseDirectories.length &&
sparseDirectories.every((entry) => presetSet.has(entry))
sparsePresetId = directoriesMatch ? preset.id : undefined
} catch {
// Why: corrupt preset data should not block creation or falsely label the new worktree.
}
}
}
const remoteTrackingBase = await resolveRemoteTrackingBaseSsh(provider, repo.path, baseBranch)
if (remoteTrackingBase) {
try {
@@ -799,7 +820,9 @@ export async function createRemoteWorktree(
repo.path,
branchName,
remotePath,
checkoutExistingBranch ? { checkoutExistingBranch } : { base: baseBranch }
checkoutExistingBranch
? { checkoutExistingBranch }
: { base: baseBranch, ...(sparseDirectories.length > 0 ? { noCheckout: true } : {}) }
)
} catch (err) {
if (
@@ -819,6 +842,18 @@ export async function createRemoteWorktree(
}
throw err
}
if (sparseDirectories.length > 0) {
try {
// Why: SSH providers expose generic git exec, so the remote sparse flow
// can mirror local addSparseWorktree without adding a relay method.
await provider.exec(['sparse-checkout', 'init', '--cone'], remotePath)
await provider.exec(['sparse-checkout', 'set', '--', ...sparseDirectories], remotePath)
await provider.exec(['checkout', branchName], remotePath)
} catch (err) {
await provider.removeWorktree(remotePath, true).catch(() => undefined)
throw err
}
}
// Re-list to get the created worktree info
const gitWorktrees = await provider.listWorktrees(repo.path)
@@ -862,12 +897,19 @@ export async function createRemoteWorktree(
? { displayName: requestedName }
: {}),
...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}),
...(sparseDirectories.length > 0
? {
sparseDirectories,
sparseBaseRef: baseBranch,
sparsePresetId
}
: {}),
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}),
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}),
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {})
}
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
@@ -1249,8 +1291,8 @@ export async function createLocalWorktree(
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}),
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}),
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {})
}
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
+129 -1
View File
@@ -204,6 +204,7 @@ describe('registerWorktreeHandlers', () => {
recordOptimisticReconcileToken: ReturnType<typeof vi.fn>
reconcileWorktreeBaseStatus: ReturnType<typeof vi.fn>
clearOptimisticReconcileToken: ReturnType<typeof vi.fn>
resolveManagedMrBase: ReturnType<typeof vi.fn>
}
beforeEach(() => {
@@ -367,11 +368,17 @@ describe('registerWorktreeHandlers', () => {
emitWorktreeBaseStatus: vi.fn(),
recordOptimisticReconcileToken: vi.fn().mockReturnValue('token-1'),
reconcileWorktreeBaseStatus: vi.fn(),
clearOptimisticReconcileToken: vi.fn()
clearOptimisticReconcileToken: vi.fn(),
resolveManagedMrBase: vi.fn().mockResolvedValue({ baseBranch: 'origin/mr-branch' })
}
registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never)
})
it('clears the GitLab MR base handler before re-registering IPC handlers', () => {
expect(removeHandlerMock).toHaveBeenCalledWith('worktrees:resolveMrBase')
expect(handlers['worktrees:resolveMrBase']).toBeDefined()
})
function mockKnownFeatureWorktree(path = '/workspace/feature-wt'): void {
listWorktreesMock.mockResolvedValue([
{
@@ -961,6 +968,31 @@ describe('registerWorktreeHandlers', () => {
})
})
it('delegates GitLab MR base resolution through the runtime implementation', async () => {
runtimeStub.resolveManagedMrBase.mockResolvedValueOnce({
baseBranch: 'fork-mr-sha',
pushTarget: { remoteName: 'origin', branchName: 'feature/mr' }
})
const result = await handlers['worktrees:resolveMrBase'](null, {
repoId: 'repo-1',
mrIid: 42,
sourceBranch: 'feature/mr',
isCrossRepository: true
})
expect(runtimeStub.resolveManagedMrBase).toHaveBeenCalledWith({
repoSelector: 'id:repo-1',
mrIid: 42,
sourceBranch: 'feature/mr',
isCrossRepository: true
})
expect(result).toEqual({
baseBranch: 'fork-mr-sha',
pushTarget: { remoteName: 'origin', branchName: 'feature/mr' }
})
})
it('persists linked issue, PR, and selected agent metadata during remote create', async () => {
const repo = {
id: 'repo-ssh',
@@ -1120,6 +1152,102 @@ describe('registerWorktreeHandlers', () => {
)
})
it('creates sparse checkout metadata and remote sparse config for SSH worktrees', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: 'origin/main'
}
const provider = {
exec: vi.fn().mockImplementation(async (args: string[]) => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
return { stdout: '', stderr: '' }
}),
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
addWorktree: vi.fn().mockResolvedValue(undefined),
removeWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi.fn().mockResolvedValue([
{
path: '/remote/sparse-dashboard',
head: 'abc123',
branch: 'refs/heads/sparse-dashboard',
isBare: false,
isSparse: true,
isMainWorktree: false
}
])
}
const mux = {
request: vi.fn().mockResolvedValue(undefined),
notify: vi.fn()
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
store.getSparsePresets.mockReturnValue([
{
id: 'preset-1',
repoId: 'repo-ssh',
name: 'App',
directories: ['apps/mobile', 'packages/shared'],
createdAt: 1,
updatedAt: 1
}
])
getSshGitProviderMock.mockReturnValue(provider)
getActiveMultiplexerMock.mockReturnValue(mux)
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-ssh',
name: 'sparse-dashboard',
sparseCheckout: {
directories: [' apps/mobile ', 'packages/shared', 'apps/mobile'],
presetId: 'preset-1'
}
})
expect(provider.addWorktree).toHaveBeenCalledWith(
'/remote/repo',
'sparse-dashboard',
'/remote/repo/../sparse-dashboard',
{ base: 'origin/main', noCheckout: true }
)
expect(provider.exec).toHaveBeenCalledWith(
['sparse-checkout', 'init', '--cone'],
'/remote/repo/../sparse-dashboard'
)
expect(provider.exec).toHaveBeenCalledWith(
['sparse-checkout', 'set', '--', 'apps/mobile', 'packages/shared'],
'/remote/repo/../sparse-dashboard'
)
expect(provider.exec).toHaveBeenCalledWith(
['checkout', 'sparse-dashboard'],
'/remote/repo/../sparse-dashboard'
)
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
'repo-ssh::/remote/sparse-dashboard',
expect.objectContaining({
sparseDirectories: ['apps/mobile', 'packages/shared'],
sparseBaseRef: 'origin/main',
sparsePresetId: 'preset-1'
})
)
expect(result).toEqual({
worktree: expect.objectContaining({
isSparse: true,
sparseDirectories: ['apps/mobile', 'packages/shared'],
sparseBaseRef: 'origin/main',
sparsePresetId: 'preset-1'
})
})
})
it('does not create an SSH worktree when remote-tracking base refresh fails', async () => {
const repo = {
id: 'repo-ssh',
+11 -95
View File
@@ -22,10 +22,8 @@ import {
} from '../git/worktree'
import { gitExecFileAsync } from '../git/runner'
import { withWorktreeSpan } from '../observability/instrumentation'
import { getDefaultRemote } from '../git/repo'
import { resolveGitHubPrStartPoint } from '../github/pr-start-point'
import { getProjectRef as getGlabProjectRef, getGlabKnownHosts } from '../gitlab/gl-utils'
import { getWorkItemByProjectRef as getGitLabWorkItemByProjectRef } from '../gitlab/client'
import { getDefaultRemote } from '../git/repo'
import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees'
import { getSshGitProvider, requireSshGitProvider } from '../providers/ssh-git-dispatch'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
@@ -234,6 +232,7 @@ export function registerWorktreeHandlers(
ipcMain.removeHandler('worktrees:list')
ipcMain.removeHandler('worktrees:create')
ipcMain.removeHandler('worktrees:resolvePrBase')
ipcMain.removeHandler('worktrees:resolveMrBase')
ipcMain.removeHandler('worktrees:remove')
ipcMain.removeHandler('worktrees:updateMeta')
ipcMain.removeHandler('worktrees:listLineage')
@@ -488,13 +487,8 @@ export function registerWorktreeHandlers(
}
)
// Why: GitLab parallel of worktrees:resolvePrBase. Same shape, same
// semantics — caller passes mrIid (with optional source_branch +
// isCrossRepository hints from the picker) and we return either a
// `<remote>/<source_branch>` ref (same-project MRs) or a SHA fetched
// from refs/merge-requests/<iid>/head (fork MRs). The returned value
// is the workspace's base ref; the new worktree branch derives from
// the workspace name, not from the source ref.
// Why: keep desktop IPC and mobile/runtime RPC on the same MR base
// resolution path so SSH repos do not regress differently by surface.
ipcMain.handle(
'worktrees:resolveMrBase',
async (
@@ -505,91 +499,13 @@ export function registerWorktreeHandlers(
sourceBranch?: string
isCrossRepository?: boolean
}
): Promise<{ baseBranch: string } | { error: string }> => {
const repo = store.getRepo(args.repoId)
if (!repo) {
return { error: 'Repo not found' }
}
// Why: parity with the gh-side guard above. Remote SSH repos are
// out of v1 scope; the picker disables the GitLab tab for them too.
if (repo.connectionId) {
return { error: 'MR start points are not supported for remote repos yet.' }
}
if (isFolderRepo(repo)) {
return { error: 'Folder mode does not support creating worktrees.' }
}
let sourceBranch = args.sourceBranch?.trim() ?? ''
let isCrossRepository = args.isCrossRepository === true
if (!sourceBranch) {
const knownHosts = await getGlabKnownHosts()
const projectRef = await getGlabProjectRef(repo.path, knownHosts)
if (!projectRef) {
return { error: 'No GitLab project found for this repository.' }
}
const item = await getGitLabWorkItemByProjectRef(repo.path, projectRef, args.mrIid, 'mr')
if (!item || item.type !== 'mr') {
return { error: `MR !${args.mrIid} not found.` }
}
sourceBranch = (item.branchName ?? '').trim()
if (!sourceBranch) {
return { error: `MR !${args.mrIid} has no source branch.` }
}
if (item.isCrossRepository === true) {
isCrossRepository = true
}
}
let remote: string
try {
remote = await getDefaultRemote(repo.path)
} catch (error) {
return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' }
}
// Why: GitLab exposes every MR head (fork or same-project) as
// refs/merge-requests/<iid>/head on the target project. Using that
// ref lets us snapshot fork MRs without configuring the fork as a
// remote — same SHA-as-baseBranch shape as the gh-side branch above.
if (isCrossRepository) {
const mrRef = `refs/merge-requests/${args.mrIid}/head`
try {
await gitExecFileAsync(['fetch', remote, mrRef], { cwd: repo.path })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { error: `Failed to fetch ${mrRef}: ${message.split('\n')[0]}` }
}
let sha: string
try {
const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', 'FETCH_HEAD'], {
cwd: repo.path
})
sha = stdout.trim()
} catch {
return { error: `Could not resolve fork MR !${args.mrIid} head after fetch.` }
}
if (!sha) {
return { error: `Empty SHA resolving fork MR !${args.mrIid} head.` }
}
return { baseBranch: sha }
}
try {
await gitExecFileAsync(['fetch', remote, sourceBranch], { cwd: repo.path })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { error: `Failed to fetch ${remote}/${sourceBranch}: ${message.split('\n')[0]}` }
}
const remoteRef = `${remote}/${sourceBranch}`
try {
await gitExecFileAsync(['rev-parse', '--verify', remoteRef], { cwd: repo.path })
} catch {
return { error: `Remote ref ${remoteRef} does not exist after fetch.` }
}
return { baseBranch: remoteRef }
): Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }> => {
return runtime.resolveManagedMrBase({
repoSelector: `id:${args.repoId}`,
mrIid: args.mrIid,
sourceBranch: args.sourceBranch,
isCrossRepository: args.isCrossRepository
})
}
)
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { mapLinearIssue } from './mappers'
describe('mapLinearIssue', () => {
it('keeps core issue details when optional Linear relations fail', async () => {
const issue = {
id: 'issue-1',
identifier: 'LIN-1',
title: 'Investigate mobile detail',
description: 'Body',
url: 'https://linear.app/acme/issue/LIN-1',
estimate: 2,
priority: 1,
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
state: Promise.reject(new Error('state fetch failed')),
team: Promise.reject(new Error('team fetch failed')),
assignee: Promise.reject(new Error('assignee fetch failed')),
project: Promise.reject(new Error('project fetch failed')),
labels: async () => ({
nodes: [{ id: 'label-1', name: 'Bug' }]
}),
children: async () => ({
nodes: [
{
id: 'child-1',
identifier: 'LIN-2',
title: 'Child',
url: 'https://linear.app/acme/issue/LIN-2'
}
]
})
}
await expect(
mapLinearIssue(issue as never, { includeChildren: true, includeProject: true })
).resolves.toMatchObject({
id: 'issue-1',
identifier: 'LIN-1',
title: 'Investigate mobile detail',
labels: ['Bug'],
subIssues: [{ id: 'child-1', identifier: 'LIN-2' }],
state: { name: '' },
team: { id: '' },
assignee: undefined,
project: undefined
})
})
})
+14 -2
View File
@@ -10,6 +10,14 @@ type MapLinearIssueOptions = {
includeProject?: boolean
}
async function optionalRelation<T>(value: Promise<T> | T): Promise<T | undefined> {
try {
return await value
} catch {
return undefined
}
}
function mapLinearIssueChild(issue: Issue): LinearIssueChildSummary {
return {
id: issue.id,
@@ -28,8 +36,12 @@ export async function mapLinearIssue(
issue: Issue | IssueSearchResult,
options: MapLinearIssueOptions = {}
): Promise<LinearIssue> {
const [state, team, assignee] = await Promise.all([issue.state, issue.team, issue.assignee])
const project = options.includeProject ? await issue.project : undefined
const [state, team, assignee, project] = await Promise.all([
optionalRelation(issue.state),
optionalRelation(issue.team),
optionalRelation(issue.assignee),
options.includeProject ? optionalRelation(issue.project) : Promise.resolve(undefined)
])
// Why: IssueSearchResult does not expose the labels() relation method — only
// the raw labelIds array. For Issue instances we resolve actual label names;
+6 -2
View File
@@ -547,12 +547,16 @@ describe('SshGitProvider', () => {
})
it('addWorktree sends git.addWorktree request', async () => {
await provider.addWorktree('/home/user/repo', 'feature', '/home/user/feat', { base: 'main' })
await provider.addWorktree('/home/user/repo', 'feature', '/home/user/feat', {
base: 'main',
noCheckout: true
})
expect(mux.request).toHaveBeenCalledWith('git.addWorktree', {
repoPath: '/home/user/repo',
branchName: 'feature',
targetDir: '/home/user/feat',
base: 'main'
base: 'main',
noCheckout: true
})
})
+1 -1
View File
@@ -373,7 +373,7 @@ export class SshGitProvider implements IGitProvider {
repoPath: string,
branchName: string,
targetDir: string,
options?: { base?: string; checkoutExistingBranch?: boolean }
options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean }
): Promise<void> {
await this.mux.request('git.addWorktree', {
repoPath,
+1 -1
View File
@@ -191,7 +191,7 @@ export type IGitProvider = {
repoPath: string,
branchName: string,
targetDir: string,
options?: { base?: string; checkoutExistingBranch?: boolean }
options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean }
): Promise<void>
removeWorktree(
worktreePath: string,
+10 -1
View File
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: Claude rate-limit fallback tests share account/keychain/PTY mocks that would be noisier split apart. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
@@ -269,7 +270,15 @@ describe('fetchClaudeRateLimits', () => {
})
)
netFetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ error: { type: 'rate_limit_error' } }), { status: 429 })
new Response(
JSON.stringify({
error: {
type: 'rate_limit_error',
message: 'Rate limited. Please try again later.'
}
}),
{ status: 429 }
)
)
await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({
@@ -0,0 +1,91 @@
import { readdirSync, readFileSync, statSync } from 'fs'
import { join } from 'path'
import { describe, expect, it } from 'vitest'
import { ALL_RPC_METHODS } from './rpc/methods'
const MOBILE_DYNAMIC_RPC_METHODS = [
// Why: computed sendRequest method names do not appear as literals in the
// mobile source scan below, but still must stay mobile-authorized.
'accounts.selectClaude',
'accounts.selectCodex',
'github.updateIssue',
'github.updatePRState',
'gitlab.updateIssue',
'gitlab.updateMR'
]
function listSourceFiles(root: string): string[] {
const entries = readdirSync(root)
const files: string[] = []
for (const entry of entries) {
const path = join(root, entry)
const stat = statSync(path)
if (stat.isDirectory()) {
files.push(...listSourceFiles(path))
continue
}
if (!/\.[cm]?[jt]sx?$/.test(entry) || /\.test\.[cm]?[jt]sx?$/.test(entry)) {
continue
}
files.push(path)
}
return files
}
function mobileLiteralRpcMethods(): string[] {
const roots = [join(process.cwd(), 'mobile/app'), join(process.cwd(), 'mobile/src')]
const methods = new Set<string>()
for (const file of roots.flatMap(listSourceFiles)) {
const source = readFileSync(file, 'utf8')
for (const match of source.matchAll(/sendRequest\(\s*['"]([^'"]+)/g)) {
methods.add(match[1]!)
}
for (const match of source.matchAll(/subscribe\(\s*['"]([^'"]+)/g)) {
methods.add(match[1]!)
}
for (const match of source.matchAll(/method:\s*['"]([^'"]+)/g)) {
const method = match[1]!
if (method.includes('.')) {
methods.add(method)
}
}
}
return [...methods].sort()
}
function mobileRpcMethods(): string[] {
return [...new Set([...mobileLiteralRpcMethods(), ...MOBILE_DYNAMIC_RPC_METHODS])].sort()
}
function mobileRpcAllowlist(): Set<string> {
const source = readFileSync(join(process.cwd(), 'src/main/runtime/runtime-rpc.ts'), 'utf8')
const allowlist = source.match(/const MOBILE_RPC_METHOD_ALLOWLIST = new Set\(\[([\s\S]*?)\]\)/)
if (!allowlist) {
throw new Error('MOBILE_RPC_METHOD_ALLOWLIST not found')
}
return new Set([...allowlist[1]!.matchAll(/'([^']+)'/g)].map((match) => match[1]!))
}
function registeredRuntimeMethods(): Set<string> {
return new Set(ALL_RPC_METHODS.map((method) => method.name))
}
describe('mobile RPC allowlist', () => {
it('allows every RPC method used by the mobile app', () => {
// Why: mobile-scoped runtime tokens are checked before dispatch. A mobile
// feature can compile and still fail at runtime if its method is missing here.
const allowed = mobileRpcAllowlist()
const missing = mobileRpcMethods().filter((method) => !allowed.has(method))
expect(missing).toEqual([])
})
it('registers every RPC method used by the mobile app', () => {
// Why: the allowlist check runs before dispatch, but an allowlisted mobile
// method still fails at runtime if it was never added to ALL_RPC_METHODS.
const registered = registeredRuntimeMethods()
const missing = mobileRpcMethods().filter((method) => !registered.has(method))
expect(missing).toEqual([])
})
})
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+85 -2
View File
@@ -11,6 +11,80 @@ function makeRequest(method: string, params?: unknown): RpcRequest {
}
describe('client UI RPC methods', () => {
it('returns the runtime host agent settings needed by mobile create flows', async () => {
const settings = {
defaultTuiAgent: 'codex',
agentCmdOverrides: { codex: 'codex --profile work' },
defaultTaskSource: 'gitlab',
defaultTaskViewPreset: 'my-prs',
visibleTaskProviders: ['github', 'gitlab'],
defaultRepoSelection: ['repo-1'],
defaultLinearTeamSelection: ['team-1'],
githubProjects: {
pinned: [],
recent: [],
lastViewByProject: {},
activeProject: null
}
}
const runtime = {
getRuntimeId: () => 'test-runtime',
getClientSettings: vi.fn(() => settings)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
const response = await dispatcher.dispatch(makeRequest('settings.get'))
expect(runtime.getClientSettings).toHaveBeenCalledTimes(1)
expect(response).toMatchObject({ ok: true, result: { settings } })
})
it('persists the runtime host task source setting for mobile Tasks', async () => {
const settings = {
defaultTuiAgent: null,
agentCmdOverrides: {},
defaultTaskSource: 'linear',
defaultTaskViewPreset: 'issues',
visibleTaskProviders: ['github', 'linear'],
defaultRepoSelection: ['repo-1', 'repo-2'],
defaultLinearTeamSelection: ['team-1', 'team-2'],
githubProjects: {
pinned: [],
recent: [],
lastViewByProject: {
'organization:stablyai:1': { viewId: 'view-1' }
},
activeProject: { owner: 'stablyai', ownerType: 'organization', number: 1 }
}
}
const runtime = {
getRuntimeId: () => 'test-runtime',
updateClientSettings: vi.fn(() => settings)
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS })
const response = await dispatcher.dispatch(
makeRequest('settings.update', {
defaultTuiAgent: 'codex',
defaultTaskSource: 'linear',
defaultTaskViewPreset: 'my-prs',
defaultRepoSelection: settings.defaultRepoSelection,
defaultLinearTeamSelection: ['team-1', 'team-2'],
githubProjects: settings.githubProjects
})
)
expect(runtime.updateClientSettings).toHaveBeenCalledWith({
defaultTuiAgent: 'codex',
defaultTaskSource: 'linear',
defaultTaskViewPreset: 'my-prs',
defaultRepoSelection: settings.defaultRepoSelection,
defaultLinearTeamSelection: ['team-1', 'team-2'],
githubProjects: settings.githubProjects
})
expect(response).toMatchObject({ ok: true, result: { settings } })
})
it('returns the runtime host persisted UI state', async () => {
const ui: PersistedUIState = {
...getDefaultUIState(),
@@ -66,7 +140,10 @@ describe('client UI RPC methods', () => {
statusBarItems: ['codex'],
taskResumeState: {
githubMode: 'items',
githubItemsQuery: 'is:open'
githubItemsQuery: 'is:open',
githubProjectHiddenFieldIdsByView: {
'project-1:view-1': ['field-1']
}
},
workspaceCleanup: {
dismissals: {
@@ -88,7 +165,13 @@ describe('client UI RPC methods', () => {
const payload = {
worktreeCardProperties: ['status', 'inline-agents'],
statusBarItems: ['codex'],
taskResumeState: { githubMode: 'items', githubItemsQuery: 'is:open' },
taskResumeState: {
githubMode: 'items',
githubItemsQuery: 'is:open',
githubProjectHiddenFieldIdsByView: {
'project-1:view-1': ['field-1']
}
},
workspaceCleanup: {
dismissals: {
'repo::/worktree': {
+50
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import type { PersistedUIState } from '../../../../shared/types'
import { defineMethod, type RpcMethod } from '../core'
@@ -27,6 +28,7 @@ const TaskResumeState = z
githubMode: z.enum(['items', 'project']).optional(),
githubItemsPreset: z.string().nullable().optional(),
githubItemsQuery: z.string().optional(),
githubProjectHiddenFieldIdsByView: z.record(z.string(), z.array(z.string())).optional(),
linearPreset: z.enum(['assigned', 'created', 'all', 'completed']).optional(),
linearQuery: z.string().optional()
})
@@ -44,6 +46,44 @@ const WorkspaceCleanup = z
dismissals: z.record(z.string(), WorkspaceCleanupDismissal)
})
.strict()
const GitHubProjectRef = z
.object({
owner: z.string(),
ownerType: z.enum(['organization', 'user']),
number: z.number().int()
})
.strict()
const GitHubProjectSettings = z
.object({
pinned: z.array(GitHubProjectRef),
recent: z.array(
GitHubProjectRef.extend({
lastOpenedAt: z.string()
}).strict()
),
lastViewByProject: z.record(z.string(), z.object({ viewId: z.string() }).strict()),
activeProject: GitHubProjectRef.nullable()
})
.strict()
const SettingsUpdate = z
.object({
defaultTuiAgent: z
.unknown()
.transform((value) =>
value === null || value === 'blank' || isTuiAgent(value) ? value : undefined
)
.optional(),
defaultTaskSource: z.enum(['github', 'gitlab', 'linear']).optional(),
defaultTaskViewPreset: z
.enum(['issues', 'my-issues', 'prs', 'my-prs', 'review', 'all'])
.optional(),
defaultRepoSelection: z.array(z.string()).nullable().optional(),
defaultLinearTeamSelection: z.array(z.string()).nullable().optional(),
githubProjects: GitHubProjectSettings.optional()
})
.strict()
.default({})
const UiUpdate = z
.object({
@@ -120,6 +160,16 @@ const UiUpdate = z
.default({})
export const CLIENT_UI_METHODS: RpcMethod[] = [
defineMethod({
name: 'settings.get',
params: null,
handler: (_params, { runtime }) => ({ settings: runtime.getClientSettings() })
}),
defineMethod({
name: 'settings.update',
params: SettingsUpdate,
handler: (params, { runtime }) => ({ settings: runtime.updateClientSettings(params) })
}),
defineMethod({
name: 'ui.get',
params: null,
@@ -300,6 +300,34 @@ describe('github RPC methods', () => {
expect(response).toMatchObject({ ok: true, result: true })
})
it('updates PR metadata on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
updateRepoPRDetails: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('github.updatePR', {
repo: 'repo-1',
prNumber: 7,
updates: { title: 'New title', body: 'Updated body' },
prRepo: { owner: 'acme', repo: 'widgets' }
})
)
expect(runtime.updateRepoPRDetails).toHaveBeenCalledWith(
'repo-1',
7,
{ title: 'New title', body: 'Updated body' },
{
owner: 'acme',
repo: 'widgets'
}
)
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})
it('merges PRs on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
@@ -323,6 +351,35 @@ describe('github RPC methods', () => {
expect(response).toMatchObject({ ok: true, result: { ok: true } })
})
it('routes PR reviewer mutations on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
requestRepoPRReviewers: vi.fn().mockResolvedValue({ ok: true }),
removeRepoPRReviewers: vi.fn().mockResolvedValue({ ok: true })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS })
const requestResponse = await dispatcher.dispatch(
makeRequest('github.requestPRReviewers', {
repo: 'repo-1',
prNumber: 7,
reviewers: ['octo']
})
)
const removeResponse = await dispatcher.dispatch(
makeRequest('github.removePRReviewers', {
repo: 'repo-1',
prNumber: 7,
reviewers: ['octo']
})
)
expect(runtime.requestRepoPRReviewers).toHaveBeenCalledWith('repo-1', 7, ['octo'])
expect(runtime.removeRepoPRReviewers).toHaveBeenCalledWith('repo-1', 7, ['octo'])
expect(requestResponse).toMatchObject({ ok: true, result: { ok: true } })
expect(removeResponse).toMatchObject({ ok: true, result: { ok: true } })
})
it('updates PR state on the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
+20
View File
@@ -103,6 +103,15 @@ const UpdatePrTitle = RepoSelector.extend({
prRepo: SlugRepo.nullable().optional()
})
const UpdatePr = RepoSelector.extend({
prNumber: z.number().int().positive(),
updates: z.object({
title: OptionalString,
body: z.string().optional()
}),
prRepo: SlugRepo.nullable().optional()
})
const MergePr = RepoSelector.extend({
prNumber: z.number().int().positive(),
method: z.enum(['merge', 'squash', 'rebase']).optional(),
@@ -398,6 +407,17 @@ export const GITHUB_METHODS: RpcMethod[] = [
handler: async (params, { runtime }) =>
runtime.updateRepoPRTitle(params.repo, params.prNumber, params.title, params.prRepo ?? null)
}),
defineMethod({
name: 'github.updatePR',
params: UpdatePr,
handler: async (params, { runtime }) =>
runtime.updateRepoPRDetails(
params.repo,
params.prNumber,
params.updates,
params.prRepo ?? null
)
}),
defineMethod({
name: 'github.mergePR',
params: MergePr,
+158
View File
@@ -0,0 +1,158 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { GITLAB_METHODS } from './gitlab'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('gitlab RPC methods', () => {
it('routes GitLab task queries and mutations to the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listGitLabRepoWorkItems: vi.fn().mockResolvedValue({ items: [] }),
listGitLabRepoTodos: vi.fn().mockResolvedValue([{ id: 1 }]),
createGitLabRepoIssue: vi.fn().mockResolvedValue({ ok: true, number: 7 }),
updateGitLabRepoIssue: vi.fn().mockResolvedValue({ ok: true }),
addGitLabRepoIssueComment: vi.fn().mockResolvedValue({ ok: true }),
addGitLabRepoMRComment: vi.fn().mockResolvedValue({ ok: true }),
mergeGitLabRepoMR: vi.fn().mockResolvedValue({ ok: true }),
updateGitLabRepoMRState: vi.fn().mockResolvedValue({ ok: true }),
updateGitLabRepoMR: vi.fn().mockResolvedValue({ ok: true }),
getGitLabRepoWorkItemDetails: vi.fn().mockResolvedValue({ body: 'Details' })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: GITLAB_METHODS })
const projectRef = { host: 'gitlab.example.com', path: 'group/project' }
await dispatcher.dispatch(
makeRequest('gitlab.listWorkItems', {
repo: 'id:repo-1',
state: 'opened',
page: 1,
perPage: 25,
query: 'bug'
})
)
await dispatcher.dispatch(
makeRequest('gitlab.createIssue', {
repo: 'id:repo-1',
title: 'Fix bug',
body: 'Details'
})
)
await dispatcher.dispatch(makeRequest('gitlab.todos', { repo: 'id:repo-1' }))
await dispatcher.dispatch(
makeRequest('gitlab.updateIssue', {
repo: 'id:repo-1',
number: 7,
updates: { state: 'closed', title: 'Done', body: 'Updated body' },
projectRef
})
)
await dispatcher.dispatch(
makeRequest('gitlab.addIssueComment', {
repo: 'id:repo-1',
number: 7,
body: 'looks good',
projectRef
})
)
await dispatcher.dispatch(
makeRequest('gitlab.addMRComment', {
repo: 'id:repo-1',
iid: 8,
body: 'ship it',
projectRef
})
)
await dispatcher.dispatch(
makeRequest('gitlab.mergeMR', {
repo: 'id:repo-1',
iid: 8,
method: 'squash',
projectRef
})
)
await dispatcher.dispatch(
makeRequest('gitlab.updateMRState', {
repo: 'id:repo-1',
iid: 8,
state: 'closed',
projectRef
})
)
await dispatcher.dispatch(
makeRequest('gitlab.updateMR', {
repo: 'id:repo-1',
iid: 8,
updates: { title: 'New title', body: 'New body', addLabels: ['bug'] },
projectRef
})
)
await dispatcher.dispatch(
makeRequest('gitlab.workItemDetails', {
repo: 'id:repo-1',
iid: 8,
type: 'mr',
projectRef
})
)
expect(runtime.listGitLabRepoWorkItems).toHaveBeenCalledWith(
'id:repo-1',
'opened',
1,
25,
'bug'
)
expect(runtime.createGitLabRepoIssue).toHaveBeenCalledWith('id:repo-1', 'Fix bug', 'Details')
expect(runtime.listGitLabRepoTodos).toHaveBeenCalledWith('id:repo-1')
expect(runtime.updateGitLabRepoIssue).toHaveBeenCalledWith(
'id:repo-1',
7,
{
state: 'closed',
title: 'Done',
body: 'Updated body'
},
projectRef
)
expect(runtime.addGitLabRepoIssueComment).toHaveBeenCalledWith(
'id:repo-1',
7,
'looks good',
projectRef
)
expect(runtime.addGitLabRepoMRComment).toHaveBeenCalledWith(
'id:repo-1',
8,
'ship it',
projectRef
)
expect(runtime.mergeGitLabRepoMR).toHaveBeenCalledWith('id:repo-1', 8, 'squash', projectRef)
expect(runtime.updateGitLabRepoMRState).toHaveBeenCalledWith(
'id:repo-1',
8,
'closed',
projectRef
)
expect(runtime.updateGitLabRepoMR).toHaveBeenCalledWith(
'id:repo-1',
8,
{
title: 'New title',
body: 'New body',
addLabels: ['bug']
},
projectRef
)
expect(runtime.getGitLabRepoWorkItemDetails).toHaveBeenCalledWith(
'id:repo-1',
8,
'mr',
projectRef
)
})
})
+151
View File
@@ -0,0 +1,151 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
const RepoSelector = z.object({
repo: requiredString('Missing repo selector')
})
const GitLabProjectRef = z
.object({
host: requiredString('Missing GitLab host'),
path: requiredString('Missing GitLab project path')
})
.optional()
const WorkItemsList = RepoSelector.extend({
state: z.enum(['opened', 'merged', 'closed', 'all']).optional(),
page: OptionalFiniteNumber,
perPage: OptionalFiniteNumber,
query: OptionalString
})
const CreateIssue = RepoSelector.extend({
title: requiredString('Missing title'),
body: z.string()
})
const IssueUpdate = z.object({
state: z.enum(['opened', 'closed']).optional(),
title: z.string().optional(),
body: z.string().optional(),
addLabels: z.array(z.string()).optional(),
removeLabels: z.array(z.string()).optional(),
addAssignees: z.array(z.string()).optional(),
removeAssignees: z.array(z.string()).optional()
})
const UpdateIssue = RepoSelector.extend({
number: z.number().int().positive(),
updates: IssueUpdate,
projectRef: GitLabProjectRef
})
const UpdateMrState = RepoSelector.extend({
iid: z.number().int().positive(),
state: z.enum(['opened', 'closed']),
projectRef: GitLabProjectRef
})
const UpdateMr = RepoSelector.extend({
iid: z.number().int().positive(),
updates: z.object({
title: z.string().optional(),
body: z.string().optional(),
addLabels: z.array(z.string()).optional(),
removeLabels: z.array(z.string()).optional()
}),
projectRef: GitLabProjectRef
})
const MergeMr = RepoSelector.extend({
iid: z.number().int().positive(),
method: z.enum(['merge', 'squash', 'rebase']).optional(),
projectRef: GitLabProjectRef
})
const AddIssueComment = RepoSelector.extend({
number: z.number().int().positive(),
body: requiredString('Comment body is required'),
projectRef: GitLabProjectRef
})
const AddMRComment = RepoSelector.extend({
iid: z.number().int().positive(),
body: requiredString('Comment body is required'),
projectRef: GitLabProjectRef
})
const WorkItemDetails = RepoSelector.extend({
iid: z.number().int().positive(),
type: z.enum(['issue', 'mr']),
projectRef: GitLabProjectRef
})
export const GITLAB_METHODS: RpcMethod[] = [
defineMethod({
name: 'gitlab.listWorkItems',
params: WorkItemsList,
handler: async (params, { runtime }) =>
runtime.listGitLabRepoWorkItems(
params.repo,
params.state,
params.page,
params.perPage,
params.query
)
}),
defineMethod({
name: 'gitlab.todos',
params: RepoSelector,
handler: async (params, { runtime }) => runtime.listGitLabRepoTodos(params.repo)
}),
defineMethod({
name: 'gitlab.createIssue',
params: CreateIssue,
handler: async (params, { runtime }) =>
runtime.createGitLabRepoIssue(params.repo, params.title, params.body)
}),
defineMethod({
name: 'gitlab.updateIssue',
params: UpdateIssue,
handler: async (params, { runtime }) =>
runtime.updateGitLabRepoIssue(params.repo, params.number, params.updates, params.projectRef)
}),
defineMethod({
name: 'gitlab.addIssueComment',
params: AddIssueComment,
handler: async (params, { runtime }) =>
runtime.addGitLabRepoIssueComment(params.repo, params.number, params.body, params.projectRef)
}),
defineMethod({
name: 'gitlab.addMRComment',
params: AddMRComment,
handler: async (params, { runtime }) =>
runtime.addGitLabRepoMRComment(params.repo, params.iid, params.body, params.projectRef)
}),
defineMethod({
name: 'gitlab.mergeMR',
params: MergeMr,
handler: async (params, { runtime }) =>
runtime.mergeGitLabRepoMR(params.repo, params.iid, params.method, params.projectRef)
}),
defineMethod({
name: 'gitlab.updateMRState',
params: UpdateMrState,
handler: async (params, { runtime }) =>
runtime.updateGitLabRepoMRState(params.repo, params.iid, params.state, params.projectRef)
}),
defineMethod({
name: 'gitlab.updateMR',
params: UpdateMr,
handler: async (params, { runtime }) =>
runtime.updateGitLabRepoMR(params.repo, params.iid, params.updates, params.projectRef)
}),
defineMethod({
name: 'gitlab.workItemDetails',
params: WorkItemDetails,
handler: async (params, { runtime }) =>
runtime.getGitLabRepoWorkItemDetails(params.repo, params.iid, params.type, params.projectRef)
})
]
+4
View File
@@ -17,8 +17,10 @@ import { SESSION_TAB_METHODS } from './session-tabs'
import { FILE_METHODS } from './files'
import { GIT_METHODS } from './git'
import { GITHUB_METHODS } from './github'
import { GITLAB_METHODS } from './gitlab'
import { HOSTED_REVIEW_METHODS } from './hosted-review'
import { LINEAR_METHODS } from './linear'
import { SSH_METHODS } from './ssh'
import { SPEECH_METHODS } from './speech'
import { CLIENT_UI_METHODS } from './client-ui'
import { WORKSPACE_PORT_METHODS } from './workspace-ports'
@@ -45,8 +47,10 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...FILE_METHODS,
...GIT_METHODS,
...GITHUB_METHODS,
...GITLAB_METHODS,
...HOSTED_REVIEW_METHODS,
...LINEAR_METHODS,
...SSH_METHODS,
...SPEECH_METHODS,
...WORKSPACE_PORT_METHODS,
...CLIENT_UI_METHODS
+102
View File
@@ -61,9 +61,78 @@ describe('repo RPC methods', () => {
})
})
it('lists sparse checkout presets for a repo', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
listSparsePresets: vi.fn().mockResolvedValue([
{
id: 'preset-1',
repoId: 'repo-1',
name: 'Frontend',
directories: ['src/renderer'],
createdAt: 1,
updatedAt: 2
}
])
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const response = await dispatcher.dispatch(
makeRequest('repo.sparsePresets', { repo: 'repo-1' })
)
expect(runtime.listSparsePresets).toHaveBeenCalledWith('repo-1')
expect(response).toMatchObject({
ok: true,
result: { presets: [{ id: 'preset-1', directories: ['src/renderer'] }] }
})
})
it('saves sparse checkout presets for a repo', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
saveSparsePreset: vi.fn().mockResolvedValue({
id: 'preset-1',
repoId: 'repo-1',
name: 'Frontend',
directories: ['src/renderer'],
createdAt: 1,
updatedAt: 2
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const response = await dispatcher.dispatch(
makeRequest('repo.saveSparsePreset', {
repo: 'repo-1',
name: 'Frontend',
directories: ['src/renderer']
})
)
expect(runtime.saveSparsePreset).toHaveBeenCalledWith('repo-1', {
name: 'Frontend',
directories: ['src/renderer']
})
expect(response).toMatchObject({
ok: true,
result: { preset: { id: 'preset-1', directories: ['src/renderer'] } }
})
})
it('routes repository hook operations to the runtime server', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getRepoHooks: vi.fn().mockResolvedValue({
hasHooksFile: true,
hooks: { scripts: { setup: 'pnpm install' } },
setupRunPolicy: 'run-by-default',
source: 'orca.yaml',
setupTrust: {
contentHash: 'hash-1',
scriptContent: 'pnpm install'
}
}),
checkRepoHooks: vi.fn().mockResolvedValue({
hasHooks: true,
hooks: { scripts: { setup: 'pnpm install' } },
@@ -88,6 +157,7 @@ describe('repo RPC methods', () => {
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const hooksResponse = await dispatcher.dispatch(makeRequest('repo.hooks', { repo: 'repo-1' }))
await dispatcher.dispatch(makeRequest('repo.hooksCheck', { repo: 'repo-1' }))
await dispatcher.dispatch(makeRequest('repo.setupScriptImports', { repo: 'repo-1' }))
await dispatcher.dispatch(makeRequest('repo.issueCommandRead', { repo: 'repo-1' }))
@@ -98,9 +168,41 @@ describe('repo RPC methods', () => {
})
)
expect(runtime.getRepoHooks).toHaveBeenCalledWith('repo-1')
expect(hooksResponse).toMatchObject({
ok: true,
result: { setupTrust: { contentHash: 'hash-1', scriptContent: 'pnpm install' } }
})
expect(runtime.checkRepoHooks).toHaveBeenCalledWith('repo-1')
expect(runtime.inspectRepoSetupScriptImports).toHaveBeenCalledWith('repo-1')
expect(runtime.readRepoIssueCommand).toHaveBeenCalledWith('repo-1')
expect(runtime.writeRepoIssueCommand).toHaveBeenCalledWith('repo-1', 'Fix it')
})
it('persists GitHub issue source preference updates', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
updateRepo: vi.fn().mockResolvedValue({
id: 'repo-1',
path: '/srv/repo',
issueSourcePreference: 'origin'
})
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
const response = await dispatcher.dispatch(
makeRequest('repo.update', {
repo: 'repo-1',
updates: { issueSourcePreference: 'origin' }
})
)
expect(runtime.updateRepo).toHaveBeenCalledWith('repo-1', {
issueSourcePreference: 'origin'
})
expect(response).toMatchObject({
ok: true,
result: { repo: { id: 'repo-1', issueSourcePreference: 'origin' } }
})
})
})
+25 -1
View File
@@ -35,7 +35,7 @@ const RepoUpdate = RepoSelector.extend({
worktreeBaseRef: OptionalString,
kind: z.enum(['git', 'folder']).optional(),
symlinkPaths: z.array(z.string()).optional(),
issueSourcePreference: z.enum(['auto', 'github', 'linear']).optional()
issueSourcePreference: z.enum(['auto', 'upstream', 'origin']).optional()
})
})
@@ -56,12 +56,36 @@ const RepoIssueCommandWrite = RepoSelector.extend({
content: z.string()
})
const RepoSparsePresetSave = RepoSelector.extend({
id: OptionalString,
name: requiredString('Missing preset name'),
directories: z.array(z.string())
})
export const REPO_METHODS: RpcMethod[] = [
defineMethod({
name: 'repo.list',
params: null,
handler: (_params, { runtime }) => ({ repos: runtime.listRepos() })
}),
defineMethod({
name: 'repo.sparsePresets',
params: RepoSelector,
handler: async (params, { runtime }) => ({
presets: await runtime.listSparsePresets(params.repo)
})
}),
defineMethod({
name: 'repo.saveSparsePreset',
params: RepoSparsePresetSave,
handler: async (params, { runtime }) => ({
preset: await runtime.saveSparsePreset(params.repo, {
...(params.id ? { id: params.id } : {}),
name: params.name,
directories: params.directories
})
})
}),
defineMethod({
name: 'repo.add',
params: RepoPath,
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { SSH_METHODS } from './ssh'
const { connectRegisteredSshTargetMock, getRegisteredSshStateMock } = vi.hoisted(() => ({
connectRegisteredSshTargetMock: vi.fn(),
getRegisteredSshStateMock: vi.fn()
}))
vi.mock('../../../ipc/ssh', () => ({
connectRegisteredSshTarget: connectRegisteredSshTargetMock,
getRegisteredSshState: getRegisteredSshStateMock
}))
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
describe('ssh RPC methods', () => {
it('returns the registered SSH target state', async () => {
const state = {
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
}
getRegisteredSshStateMock.mockReturnValueOnce(state)
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
const response = await dispatcher.dispatch(makeRequest('ssh.getState', { targetId: 'ssh-1' }))
expect(getRegisteredSshStateMock).toHaveBeenCalledWith('ssh-1')
expect(response).toMatchObject({ ok: true, result: { state } })
})
it('connects through the registered desktop SSH lifecycle', async () => {
const state = {
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
}
connectRegisteredSshTargetMock.mockResolvedValueOnce(state)
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
const response = await dispatcher.dispatch(makeRequest('ssh.connect', { targetId: 'ssh-1' }))
expect(connectRegisteredSshTargetMock).toHaveBeenCalledWith('ssh-1')
expect(response).toMatchObject({ ok: true, result: { state } })
})
it('returns null when the target has no registered state yet', async () => {
getRegisteredSshStateMock.mockReturnValueOnce(undefined)
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
const response = await dispatcher.dispatch(makeRequest('ssh.getState', { targetId: 'ssh-1' }))
expect(response).toMatchObject({ ok: true, result: { state: null } })
})
})
+20
View File
@@ -0,0 +1,20 @@
import { z } from 'zod'
import { connectRegisteredSshTarget, getRegisteredSshState } from '../../../ipc/ssh'
import { defineMethod, type RpcMethod } from '../core'
const SshTarget = z.object({
targetId: z.string().min(1)
})
export const SSH_METHODS: RpcMethod[] = [
defineMethod({
name: 'ssh.getState',
params: SshTarget,
handler: (params) => ({ state: getRegisteredSshState(params.targetId) ?? null })
}),
defineMethod({
name: 'ssh.connect',
params: SshTarget,
handler: async (params) => ({ state: await connectRegisteredSshTarget(params.targetId) })
})
]
+4 -2
View File
@@ -338,7 +338,8 @@ const TerminalSplit = TerminalHandle.extend({
.transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined))
.pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()]))
.optional(),
command: OptionalString
command: OptionalString,
env: z.record(z.string(), z.string()).optional()
})
const TerminalStop = z.object({
@@ -571,7 +572,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [
handler: async (params, { runtime }) => ({
split: await runtime.splitTerminal(params.terminal, {
direction: params.direction,
command: params.command
command: params.command,
env: params.env
})
})
}),
@@ -0,0 +1,173 @@
import { z } from 'zod'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
import {
OptionalBoolean,
OptionalFiniteNumber,
OptionalPlainString,
OptionalString,
TriStateLinkedIssue
} from '../schemas'
export const WorktreeListParams = z.object({
repo: OptionalString,
limit: OptionalFiniteNumber
})
export const WorktreePsParams = z.object({
limit: OptionalFiniteNumber
})
export const WorktreeSortOrder = z.object({
orderedIds: z.array(z.string())
})
export const WorktreeSelector = z.object({
worktree: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing worktree selector'))
})
export const WorktreeCreate = z
.object({
repo: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing repo selector')),
name: OptionalString,
baseBranch: OptionalString,
branchNameOverride: OptionalString,
linkedIssue: TriStateLinkedIssue,
linkedPR: TriStateLinkedIssue,
linkedLinearIssue: z.string().optional(),
linkedGitLabMR: TriStateLinkedIssue,
linkedGitLabIssue: TriStateLinkedIssue,
comment: OptionalString,
displayName: OptionalString,
workspaceStatus: OptionalString,
manualOrder: OptionalFiniteNumber,
sparseCheckout: z
.object({
directories: z.array(z.string()),
presetId: OptionalString
})
.optional(),
pushTarget: z
.object({
remoteName: z.string(),
branchName: z.string(),
remoteUrl: OptionalString
})
.optional(),
runHooks: OptionalBoolean,
activate: OptionalBoolean,
parentWorktree: OptionalString,
cwdParentWorktree: OptionalString,
noParent: OptionalBoolean,
callerTerminalHandle: OptionalString,
orchestrationContext: z
.object({
parentWorktreeId: OptionalString,
orchestrationRunId: OptionalString,
taskId: OptionalString,
coordinatorHandle: OptionalString
})
.optional(),
setupDecision: z
.unknown()
.transform((v) =>
typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined
)
.pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()]))
.optional(),
// Why: mobile clients pass a startup command (e.g. 'claude') so the first
// terminal pane launches the selected agent instead of an idle shell.
startupCommand: OptionalString,
// Why: task-driven mobile creates need desktop parity: the host chooses
// the same default/detected agent and drafts the linked issue/PR URL into it.
startupDraft: OptionalString,
createdWithAgent: z
.unknown()
.transform((value) => (isTuiAgent(value) ? value : undefined))
.optional()
})
.superRefine((params, ctx) => {
if (params.parentWorktree && params.noParent === true) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Choose either --parent-worktree or --no-parent, not both.'
})
}
})
export const WorktreeSet = WorktreeSelector.extend({
displayName: OptionalString,
// Why: empty comments are meaningful metadata updates, so use the plain
// string parser instead of OptionalString's empty-as-undefined behavior.
comment: OptionalPlainString,
linkedIssue: TriStateLinkedIssue,
linkedPR: TriStateLinkedIssue,
linkedLinearIssue: z.union([z.string(), z.null()]).optional(),
linkedGitLabMR: TriStateLinkedIssue,
linkedGitLabIssue: TriStateLinkedIssue,
isArchived: OptionalBoolean,
isUnread: OptionalBoolean,
isPinned: OptionalBoolean,
sortOrder: OptionalFiniteNumber,
manualOrder: OptionalFiniteNumber,
lastActivityAt: OptionalFiniteNumber,
createdAt: OptionalFiniteNumber,
sparseDirectories: z.array(z.string()).optional(),
sparseBaseRef: OptionalString,
sparsePresetId: OptionalString,
baseRef: OptionalString,
workspaceStatus: OptionalString,
pushTarget: z
.object({
remoteName: z.string(),
branchName: z.string(),
remoteUrl: OptionalString
})
.optional(),
diffComments: z.array(z.unknown()).optional(),
parentWorktree: OptionalString,
noParent: OptionalBoolean
}).superRefine((params, ctx) => {
if (params.parentWorktree && params.noParent === true) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Choose either --parent-worktree or --no-parent, not both.'
})
}
})
export const WorktreeRemove = WorktreeSelector.extend({
force: OptionalBoolean,
runHooks: OptionalBoolean
})
export const WorktreeResolvePrBase = z.object({
repo: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing repo selector')),
prNumber: z
.unknown()
.transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0))
.pipe(z.number().int().positive('Missing PR number')),
headRefName: OptionalString,
isCrossRepository: OptionalBoolean
})
export const WorktreeResolveMrBase = z.object({
repo: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing repo selector')),
mrIid: z
.unknown()
.transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0))
.pipe(z.number().int().positive('Missing MR number')),
sourceBranch: OptionalString,
isCrossRepository: OptionalBoolean
})
@@ -57,6 +57,7 @@ describe('worktree RPC methods', () => {
setupDecision: 'skip',
createdWithAgent: undefined,
startup: undefined,
startupDraft: undefined,
lineage: {
parentWorktree: 'id:parent',
noParent: false,
@@ -66,6 +67,35 @@ describe('worktree RPC methods', () => {
})
})
it('forwards task startup drafts to runtime worktree creation', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
createManagedWorktree: vi.fn().mockResolvedValue({ worktree: { id: 'wt-1' } })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
await dispatcher.dispatch(
makeRequest('worktree.create', {
repo: 'repo-1',
name: 'issue-123',
startupDraft: 'https://github.com/stablyai/orca/issues/123',
createdWithAgent: 'codex',
activate: true
})
)
expect(runtime.createManagedWorktree).toHaveBeenCalledWith(
expect.objectContaining({
repoSelector: 'repo-1',
name: 'issue-123',
activate: true,
createdWithAgent: 'codex',
startup: undefined,
startupDraft: 'https://github.com/stablyai/orca/issues/123'
})
)
})
it('rejects worktree.create when both parent and no-parent are supplied', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
@@ -87,6 +117,56 @@ describe('worktree RPC methods', () => {
expect(runtime.createManagedWorktree).not.toHaveBeenCalled()
})
it('passes explicit repo selectors to PR base resolution', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
resolveManagedPrBase: vi.fn().mockResolvedValue({ baseBranch: 'origin/pr-head' })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('worktree.resolvePrBase', {
repo: 'id:repo-1',
prNumber: 42,
headRefName: 'feature/pr-head',
isCrossRepository: false
})
)
expect(response).toMatchObject({ ok: true })
expect(runtime.resolveManagedPrBase).toHaveBeenCalledWith({
repoSelector: 'id:repo-1',
prNumber: 42,
headRefName: 'feature/pr-head',
isCrossRepository: false
})
})
it('passes explicit repo selectors to MR base resolution', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
resolveManagedMrBase: vi.fn().mockResolvedValue({ baseBranch: 'origin/mr-head' })
} as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
const response = await dispatcher.dispatch(
makeRequest('worktree.resolveMrBase', {
repo: 'id:repo-1',
mrIid: 42,
sourceBranch: 'feature/mr-head',
isCrossRepository: false
})
)
expect(response).toMatchObject({ ok: true })
expect(runtime.resolveManagedMrBase).toHaveBeenCalledWith({
repoSelector: 'id:repo-1',
mrIid: 42,
sourceBranch: 'feature/mr-head',
isCrossRepository: false
})
})
it('rejects worktree.set when both parent and no-parent are supplied', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
+23 -157
View File
@@ -1,161 +1,15 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import {
OptionalBoolean,
OptionalFiniteNumber,
OptionalPlainString,
OptionalString,
TriStateLinkedIssue
} from '../schemas'
import { isTuiAgent } from '../../../../shared/tui-agent-config'
const WorktreeListParams = z.object({
repo: OptionalString,
limit: OptionalFiniteNumber
})
const WorktreePsParams = z.object({
limit: OptionalFiniteNumber
})
const WorktreeSortOrder = z.object({
orderedIds: z.array(z.string())
})
const WorktreeSelector = z.object({
worktree: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing worktree selector'))
})
const WorktreeCreate = z
.object({
repo: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing repo selector')),
name: OptionalString,
baseBranch: OptionalString,
branchNameOverride: OptionalString,
linkedIssue: TriStateLinkedIssue,
linkedPR: TriStateLinkedIssue,
linkedLinearIssue: z.string().optional(),
linkedGitLabMR: TriStateLinkedIssue,
linkedGitLabIssue: TriStateLinkedIssue,
comment: OptionalString,
displayName: OptionalString,
workspaceStatus: OptionalString,
manualOrder: OptionalFiniteNumber,
sparseCheckout: z
.object({
directories: z.array(z.string()),
presetId: OptionalString
})
.optional(),
pushTarget: z
.object({
remoteName: z.string(),
branchName: z.string(),
remoteUrl: OptionalString
})
.optional(),
runHooks: OptionalBoolean,
activate: OptionalBoolean,
parentWorktree: OptionalString,
cwdParentWorktree: OptionalString,
noParent: OptionalBoolean,
callerTerminalHandle: OptionalString,
orchestrationContext: z
.object({
parentWorktreeId: OptionalString,
orchestrationRunId: OptionalString,
taskId: OptionalString,
coordinatorHandle: OptionalString
})
.optional(),
setupDecision: z
.unknown()
.transform((v) =>
typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined
)
.pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()]))
.optional(),
// Why: mobile clients pass a startup command (e.g. 'claude') so the first
// terminal pane launches the selected agent instead of an idle shell.
startupCommand: OptionalString,
createdWithAgent: z
.unknown()
.transform((value) => (isTuiAgent(value) ? value : undefined))
.optional()
})
.superRefine((params, ctx) => {
if (params.parentWorktree && params.noParent === true) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Choose either --parent-worktree or --no-parent, not both.'
})
}
})
const WorktreeSet = WorktreeSelector.extend({
displayName: OptionalString,
// Why: empty comments are meaningful metadata updates, so use the plain
// string parser instead of OptionalString's empty-as-undefined behavior.
comment: OptionalPlainString,
linkedIssue: TriStateLinkedIssue,
linkedPR: TriStateLinkedIssue,
linkedLinearIssue: z.union([z.string(), z.null()]).optional(),
linkedGitLabMR: TriStateLinkedIssue,
linkedGitLabIssue: TriStateLinkedIssue,
isArchived: OptionalBoolean,
isUnread: OptionalBoolean,
isPinned: OptionalBoolean,
sortOrder: OptionalFiniteNumber,
manualOrder: OptionalFiniteNumber,
lastActivityAt: OptionalFiniteNumber,
createdAt: OptionalFiniteNumber,
sparseDirectories: z.array(z.string()).optional(),
sparseBaseRef: OptionalString,
sparsePresetId: OptionalString,
baseRef: OptionalString,
workspaceStatus: OptionalString,
pushTarget: z
.object({
remoteName: z.string(),
branchName: z.string(),
remoteUrl: OptionalString
})
.optional(),
diffComments: z.array(z.unknown()).optional(),
parentWorktree: OptionalString,
noParent: OptionalBoolean
}).superRefine((params, ctx) => {
if (params.parentWorktree && params.noParent === true) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Choose either --parent-worktree or --no-parent, not both.'
})
}
})
const WorktreeRemove = WorktreeSelector.extend({
force: OptionalBoolean,
runHooks: OptionalBoolean
})
const WorktreeResolvePrBase = z.object({
repo: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing repo selector')),
prNumber: z
.unknown()
.transform((v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0))
.pipe(z.number().int().positive('Missing PR number')),
headRefName: OptionalString,
isCrossRepository: OptionalBoolean
})
WorktreeCreate,
WorktreeListParams,
WorktreePsParams,
WorktreeRemove,
WorktreeResolveMrBase,
WorktreeResolvePrBase,
WorktreeSelector,
WorktreeSet,
WorktreeSortOrder
} from './worktree-schemas'
export const WORKTREE_METHODS: RpcMethod[] = [
defineMethod({
@@ -215,6 +69,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [
setupDecision: params.setupDecision,
createdWithAgent: params.createdWithAgent,
startup: params.startupCommand ? { command: params.startupCommand } : undefined,
startupDraft: params.startupDraft,
lineage: {
parentWorktree: params.parentWorktree,
...(params.cwdParentWorktree ? { cwdParentWorktree: params.cwdParentWorktree } : {}),
@@ -271,12 +126,23 @@ export const WORKTREE_METHODS: RpcMethod[] = [
params: WorktreeResolvePrBase,
handler: async (params, { runtime }) =>
runtime.resolveManagedPrBase({
repoId: params.repo,
repoSelector: params.repo,
prNumber: params.prNumber,
headRefName: params.headRefName,
isCrossRepository: params.isCrossRepository
})
}),
defineMethod({
name: 'worktree.resolveMrBase',
params: WorktreeResolveMrBase,
handler: async (params, { runtime }) =>
runtime.resolveManagedMrBase({
repoSelector: params.repo,
mrIid: params.mrIid,
sourceBranch: params.sourceBranch,
isCrossRepository: params.isCrossRepository
})
}),
defineMethod({
name: 'worktree.rm',
params: WorktreeRemove,
+723 -1
View File
@@ -779,6 +779,57 @@ describe('OrcaRuntimeRpcServer', () => {
const browserSetViewport = vi.fn().mockResolvedValue({ ok: true })
const browserDialogAccept = vi.fn().mockResolvedValue({ ok: true })
const browserDialogDismiss = vi.fn().mockResolvedValue({ ok: true })
const listGitHubProjects = vi.fn().mockResolvedValue({ ok: true, projects: [] })
const listGitHubLabelsBySlug = vi.fn().mockResolvedValue({ ok: true, labels: ['bug'] })
const listGitHubAssignableUsersBySlug = vi
.fn()
.mockResolvedValue({ ok: true, users: [{ login: 'alex' }] })
const listGitHubIssueTypesBySlug = vi.fn().mockResolvedValue({
ok: true,
types: [{ id: 'type-1', name: 'Bug', color: 'RED', description: null }]
})
const updateGitHubProjectItemField = vi.fn().mockResolvedValue({ ok: true })
const clearGitHubProjectItemField = vi.fn().mockResolvedValue({ ok: true })
const updateGitHubIssueBySlug = vi.fn().mockResolvedValue({ ok: true })
const updateGitHubIssueTypeBySlug = vi.fn().mockResolvedValue({ ok: true })
const updateGitHubPullRequestBySlug = vi.fn().mockResolvedValue({ ok: true })
const updateRepoIssue = vi.fn().mockResolvedValue({ ok: true })
const listRepoLabels = vi.fn().mockResolvedValue(['bug'])
const listRepoAssignableUsers = vi.fn().mockResolvedValue([{ login: 'alex' }])
const addRepoIssueComment = vi.fn().mockResolvedValue({ ok: true, comment: { id: 2 } })
const addRepoPRReviewComment = vi.fn().mockResolvedValue({ ok: true, comment: { id: 3 } })
const addRepoPRReviewCommentReply = vi.fn().mockResolvedValue({
ok: true,
comment: { id: 4 }
})
const getRepoPRFileContents = vi.fn().mockResolvedValue({
original: 'before',
modified: 'after',
originalIsBinary: false,
modifiedIsBinary: false
})
const rerunRepoPRChecks = vi.fn().mockResolvedValue({ ok: true, count: 1 })
const resolveRepoReviewThread = vi.fn().mockResolvedValue(true)
const setRepoPRFileViewed = vi.fn().mockResolvedValue(true)
const requestRepoPRReviewers = vi.fn().mockResolvedValue({ ok: true })
const mergeRepoPR = vi.fn().mockResolvedValue({ ok: true })
const addGitLabRepoIssueComment = vi.fn().mockResolvedValue({ ok: true })
const addGitLabRepoMRComment = vi.fn().mockResolvedValue({ ok: true })
const mergeGitLabRepoMR = vi.fn().mockResolvedValue({ ok: true })
const addGitHubIssueCommentBySlug = vi.fn().mockResolvedValue({
ok: true,
comment: { id: 1, author: 'me', body: 'done', createdAt: '2026-01-01T00:00:00Z', url: '' }
})
const updateGitHubIssueCommentBySlug = vi.fn().mockResolvedValue({ ok: true })
const deleteGitHubIssueCommentBySlug = vi.fn().mockResolvedValue({ ok: true })
const linearSearchIssues = vi.fn().mockResolvedValue([])
const linearSelectWorkspace = vi.fn().mockReturnValue({
connected: true,
selectedWorkspaceId: 'workspace-1'
})
const linearTeamLabels = vi.fn().mockResolvedValue([{ id: 'label-1', name: 'bug' }])
const linearTeamMembers = vi.fn().mockResolvedValue([{ id: 'member-1', displayName: 'Alex' }])
const linearAddIssueComment = vi.fn().mockResolvedValue({ ok: true, id: 'comment-1' })
const runtime = {
getRuntimeId: () => 'test-runtime',
getStatus: vi.fn().mockResolvedValue({ graphStatus: 'ok' }),
@@ -796,7 +847,41 @@ describe('OrcaRuntimeRpcServer', () => {
browserTabCreate,
browserSetViewport,
browserDialogAccept,
browserDialogDismiss
browserDialogDismiss,
listGitHubProjects,
listGitHubLabelsBySlug,
listGitHubAssignableUsersBySlug,
listGitHubIssueTypesBySlug,
updateGitHubProjectItemField,
clearGitHubProjectItemField,
updateGitHubIssueBySlug,
updateGitHubIssueTypeBySlug,
updateGitHubPullRequestBySlug,
updateRepoIssue,
listRepoLabels,
listRepoAssignableUsers,
addRepoIssueComment,
addRepoPRReviewComment,
addRepoPRReviewCommentReply,
getRepoPRFileContents,
rerunRepoPRChecks,
resolveRepoReviewThread,
setRepoPRFileViewed,
requestRepoPRReviewers,
mergeRepoPR,
addGitLabRepoIssueComment,
addGitLabRepoMRComment,
mergeGitLabRepoMR,
addGitHubIssueCommentBySlug,
updateGitHubIssueCommentBySlug,
deleteGitHubIssueCommentBySlug,
linearSearchIssues,
linearSelectWorkspace,
linearTeamLabels,
linearTeamMembers,
linearAddIssueComment,
getClientSettings: vi.fn(() => ({ defaultTuiAgent: 'codex', agentCmdOverrides: {} })),
updateClientSettings: vi.fn(() => ({ defaultTaskSource: 'linear' }))
} as unknown as OrcaRuntimeService
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false })
server['deviceRegistry'] = new DeviceRegistry(userDataPath)
@@ -822,6 +907,447 @@ describe('OrcaRuntimeRpcServer', () => {
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_settings_get',
method: 'settings.get',
deviceToken: mobile.token
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_settings_update',
method: 'settings.update',
deviceToken: mobile.token,
params: { defaultTaskSource: 'linear' }
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_github_projects',
method: 'github.project.listAccessible',
deviceToken: mobile.token,
params: {}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_project_issue_types',
method: 'github.project.listIssueTypesBySlug',
deviceToken: mobile.token,
params: { owner: 'stablyai', repo: 'orca' }
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_project_labels',
method: 'github.project.listLabelsBySlug',
deviceToken: mobile.token,
params: { owner: 'stablyai', repo: 'orca' }
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_project_assignees',
method: 'github.project.listAssignableUsersBySlug',
deviceToken: mobile.token,
params: { owner: 'stablyai', repo: 'orca', seedLogins: ['alex'] }
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_project_update_issue',
method: 'github.project.updateIssueBySlug',
deviceToken: mobile.token,
params: {
owner: 'stablyai',
repo: 'orca',
number: 123,
updates: { title: 'New title' }
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_project_update_issue_type',
method: 'github.project.updateIssueTypeBySlug',
deviceToken: mobile.token,
params: {
owner: 'stablyai',
repo: 'orca',
number: 123,
issueTypeId: 'type-1'
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_project_update_field',
method: 'github.project.updateItemField',
deviceToken: mobile.token,
params: {
projectId: 'project-1',
itemId: 'item-1',
fieldId: 'field-1',
value: { kind: 'text', text: 'Ready' }
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_project_clear_field',
method: 'github.project.clearItemField',
deviceToken: mobile.token,
params: {
projectId: 'project-1',
itemId: 'item-1',
fieldId: 'field-1'
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_project_update_pr',
method: 'github.project.updatePullRequestBySlug',
deviceToken: mobile.token,
params: {
owner: 'stablyai',
repo: 'orca',
number: 456,
updates: { state: 'closed' }
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_project_add_comment',
method: 'github.project.addIssueCommentBySlug',
deviceToken: mobile.token,
params: {
owner: 'stablyai',
repo: 'orca',
number: 123,
body: 'done'
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_project_update_comment',
method: 'github.project.updateIssueCommentBySlug',
deviceToken: mobile.token,
params: {
owner: 'stablyai',
repo: 'orca',
commentId: 101,
body: 'edited'
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_project_delete_comment',
method: 'github.project.deleteIssueCommentBySlug',
deviceToken: mobile.token,
params: {
owner: 'stablyai',
repo: 'orca',
commentId: 101
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_github_update_issue',
method: 'github.updateIssue',
deviceToken: mobile.token,
params: {
repo: 'id:repo-1',
number: 123,
updates: { title: 'New title', addLabels: ['bug'] }
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_github_labels',
method: 'github.listLabels',
deviceToken: mobile.token,
params: { repo: 'id:repo-1' }
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_github_assignees',
method: 'github.listAssignableUsers',
deviceToken: mobile.token,
params: { repo: 'id:repo-1' }
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_github_add_comment',
method: 'github.addIssueComment',
deviceToken: mobile.token,
params: {
repo: 'id:repo-1',
number: 123,
body: 'done'
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_github_add_review_comment',
method: 'github.addPRReviewComment',
deviceToken: mobile.token,
params: {
repo: 'id:repo-1',
prNumber: 456,
commitId: 'abc123',
path: 'src/app.ts',
line: 10,
body: 'please fix'
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_github_reply_review_comment',
method: 'github.addPRReviewCommentReply',
deviceToken: mobile.token,
params: {
repo: 'id:repo-1',
prNumber: 456,
commentId: 99,
body: 'fixed',
threadId: 'thread-1',
path: 'src/app.ts',
line: 10
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_github_pr_file_contents',
method: 'github.prFileContents',
deviceToken: mobile.token,
params: {
repo: 'id:repo-1',
prNumber: 456,
path: 'src/app.ts',
status: 'modified',
headSha: 'abc123',
baseSha: 'def456'
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_github_rerun_checks',
method: 'github.rerunPRChecks',
deviceToken: mobile.token,
params: {
repo: 'id:repo-1',
prNumber: 456,
headSha: 'abc123',
failedOnly: true
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_github_resolve_thread',
method: 'github.resolveReviewThread',
deviceToken: mobile.token,
params: {
repo: 'id:repo-1',
threadId: 'thread-1',
resolve: true
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_github_file_viewed',
method: 'github.setPRFileViewed',
deviceToken: mobile.token,
params: {
repo: 'id:repo-1',
pullRequestId: 'PR_kw',
path: 'src/app.ts',
viewed: true
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_github_request_reviewers',
method: 'github.requestPRReviewers',
deviceToken: mobile.token,
params: {
repo: 'id:repo-1',
prNumber: 456,
reviewers: ['alex']
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_github_merge_pr',
method: 'github.mergePR',
deviceToken: mobile.token,
params: {
repo: 'id:repo-1',
prNumber: 456,
method: 'squash'
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_gitlab_add_issue_comment',
method: 'gitlab.addIssueComment',
deviceToken: mobile.token,
params: {
repo: 'id:repo-1',
number: 123,
body: 'done'
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_gitlab_add_mr_comment',
method: 'gitlab.addMRComment',
deviceToken: mobile.token,
params: {
repo: 'id:repo-1',
iid: 456,
body: 'ship it'
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_gitlab_merge_mr',
method: 'gitlab.mergeMR',
deviceToken: mobile.token,
params: {
repo: 'id:repo-1',
iid: 456,
method: 'merge'
}
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_linear_search',
method: 'linear.searchIssues',
deviceToken: mobile.token,
params: { query: 'auth', limit: 10, workspaceId: 'workspace-1' }
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_linear_select_workspace',
method: 'linear.selectWorkspace',
deviceToken: mobile.token,
params: { workspaceId: 'workspace-1' }
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_linear_team_labels',
method: 'linear.teamLabels',
deviceToken: mobile.token,
params: { teamId: 'team-1', workspaceId: 'workspace-1' }
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_linear_team_members',
method: 'linear.teamMembers',
deviceToken: mobile.token,
params: { teamId: 'team-1', workspaceId: 'workspace-1' }
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_linear_add_comment',
method: 'linear.addIssueComment',
deviceToken: mobile.token,
params: { issueId: 'issue-1', workspaceId: 'workspace-1', body: 'done' }
}),
(response) => replies.push(JSON.parse(response) as Record<string, unknown>),
() => {}
)
await server['handleWebSocketMessage'](
JSON.stringify({
id: 'req_git_status',
@@ -981,6 +1507,92 @@ describe('OrcaRuntimeRpcServer', () => {
})
)
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_allowed', ok: true }))
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_settings_get', ok: true }))
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_settings_update', ok: true }))
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_github_projects', ok: true }))
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_project_issue_types', ok: true })
)
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_project_labels', ok: true }))
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_project_assignees', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_project_update_issue', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_project_update_issue_type', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_project_update_field', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_project_clear_field', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_project_update_pr', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_project_add_comment', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_project_update_comment', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_project_delete_comment', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_github_update_issue', ok: true })
)
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_github_labels', ok: true }))
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_github_assignees', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_github_add_comment', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_github_add_review_comment', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_github_reply_review_comment', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_github_pr_file_contents', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_github_rerun_checks', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_github_resolve_thread', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_github_file_viewed', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_github_request_reviewers', ok: true })
)
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_github_merge_pr', ok: true }))
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_gitlab_add_issue_comment', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_gitlab_add_mr_comment', ok: true })
)
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_gitlab_merge_mr', ok: true }))
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_linear_search', ok: true }))
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_linear_select_workspace', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_linear_team_labels', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_linear_team_members', ok: true })
)
expect(replies).toContainEqual(
expect.objectContaining({ id: 'req_linear_add_comment', ok: true })
)
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_status', ok: true }))
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_push', ok: true }))
expect(replies).toContainEqual(expect.objectContaining({ id: 'req_git_upstream', ok: true }))
@@ -1038,6 +1650,116 @@ describe('OrcaRuntimeRpcServer', () => {
worktree: 'id:wt-1',
page: 'page-1'
})
expect(listGitHubIssueTypesBySlug).toHaveBeenCalledWith({
owner: 'stablyai',
repo: 'orca'
})
expect(listGitHubLabelsBySlug).toHaveBeenCalledWith({
owner: 'stablyai',
repo: 'orca'
})
expect(listGitHubAssignableUsersBySlug).toHaveBeenCalledWith({
owner: 'stablyai',
repo: 'orca',
seedLogins: ['alex']
})
expect(updateGitHubIssueBySlug).toHaveBeenCalledWith({
owner: 'stablyai',
repo: 'orca',
number: 123,
updates: { title: 'New title' }
})
expect(updateGitHubIssueTypeBySlug).toHaveBeenCalledWith({
owner: 'stablyai',
repo: 'orca',
number: 123,
issueTypeId: 'type-1'
})
expect(updateGitHubPullRequestBySlug).toHaveBeenCalledWith({
owner: 'stablyai',
repo: 'orca',
number: 456,
updates: { state: 'closed' }
})
expect(addGitHubIssueCommentBySlug).toHaveBeenCalledWith({
owner: 'stablyai',
repo: 'orca',
number: 123,
body: 'done'
})
expect(updateGitHubIssueCommentBySlug).toHaveBeenCalledWith({
owner: 'stablyai',
repo: 'orca',
commentId: 101,
body: 'edited'
})
expect(deleteGitHubIssueCommentBySlug).toHaveBeenCalledWith({
owner: 'stablyai',
repo: 'orca',
commentId: 101
})
expect(updateRepoIssue).toHaveBeenCalledWith('id:repo-1', 123, {
title: 'New title',
addLabels: ['bug']
})
expect(listRepoLabels).toHaveBeenCalledWith('id:repo-1')
expect(listRepoAssignableUsers).toHaveBeenCalledWith('id:repo-1')
expect(addRepoIssueComment).toHaveBeenCalledWith('id:repo-1', 123, 'done')
expect(addRepoPRReviewComment).toHaveBeenCalledWith('id:repo-1', {
prNumber: 456,
commitId: 'abc123',
path: 'src/app.ts',
line: 10,
startLine: undefined,
body: 'please fix'
})
expect(addRepoPRReviewCommentReply).toHaveBeenCalledWith('id:repo-1', {
prNumber: 456,
commentId: 99,
body: 'fixed',
threadId: 'thread-1',
path: 'src/app.ts',
line: 10
})
expect(getRepoPRFileContents).toHaveBeenCalledWith('id:repo-1', {
prNumber: 456,
path: 'src/app.ts',
oldPath: undefined,
status: 'modified',
headSha: 'abc123',
baseSha: 'def456'
})
expect(rerunRepoPRChecks).toHaveBeenCalledWith('id:repo-1', 456, {
headSha: 'abc123',
failedOnly: true
})
expect(resolveRepoReviewThread).toHaveBeenCalledWith('id:repo-1', 'thread-1', true)
expect(setRepoPRFileViewed).toHaveBeenCalledWith('id:repo-1', {
pullRequestId: 'PR_kw',
path: 'src/app.ts',
viewed: true
})
expect(requestRepoPRReviewers).toHaveBeenCalledWith('id:repo-1', 456, ['alex'])
expect(mergeRepoPR).toHaveBeenCalledWith('id:repo-1', 456, 'squash', null)
expect(addGitLabRepoIssueComment).toHaveBeenCalledWith('id:repo-1', 123, 'done', undefined)
expect(addGitLabRepoMRComment).toHaveBeenCalledWith('id:repo-1', 456, 'ship it', undefined)
expect(mergeGitLabRepoMR).toHaveBeenCalledWith('id:repo-1', 456, 'merge', undefined)
expect(updateGitHubProjectItemField).toHaveBeenCalledWith({
projectId: 'project-1',
itemId: 'item-1',
fieldId: 'field-1',
value: { kind: 'text', text: 'Ready' }
})
expect(clearGitHubProjectItemField).toHaveBeenCalledWith({
projectId: 'project-1',
itemId: 'item-1',
fieldId: 'field-1'
})
expect(linearSearchIssues).toHaveBeenCalledWith('auth', 10, 'workspace-1')
expect(linearSelectWorkspace).toHaveBeenCalledWith('workspace-1')
expect(linearTeamLabels).toHaveBeenCalledWith('team-1', 'workspace-1')
expect(linearTeamMembers).toHaveBeenCalledWith('team-1', 'workspace-1')
expect(linearAddIssueComment).toHaveBeenCalledWith('issue-1', 'done', 'workspace-1')
expect(removeClaudeAccount).not.toHaveBeenCalled()
})
+80
View File
@@ -156,12 +156,82 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'git.status',
'git.unstage',
'git.upstreamStatus',
'github.createIssue',
'github.addIssueComment',
'github.addPRReviewComment',
'github.addPRReviewCommentReply',
'github.countWorkItems',
'github.listAssignableUsers',
'github.listLabels',
'github.listWorkItems',
'github.mergePR',
'github.requestPRReviewers',
'github.project.listAccessible',
'github.project.listAssignableUsersBySlug',
'github.project.listIssueTypesBySlug',
'github.project.listLabelsBySlug',
'github.project.listViews',
'github.project.resolveRef',
'github.project.addIssueCommentBySlug',
'github.project.updateIssueCommentBySlug',
'github.project.deleteIssueCommentBySlug',
'github.project.clearItemField',
'github.project.updateIssueBySlug',
'github.project.updateIssueTypeBySlug',
'github.project.updateItemField',
'github.project.updatePullRequestBySlug',
'github.project.viewTable',
'github.project.workItemDetailsBySlug',
'github.prFileContents',
'github.prChecks',
'github.rerunPRChecks',
'github.resolveReviewThread',
'github.setPRFileViewed',
'github.updateIssue',
'github.updatePR',
'github.updatePRTitle',
'github.updatePRState',
'github.repoSlug',
'github.workItem',
'github.workItemDetails',
'gitlab.createIssue',
'gitlab.addIssueComment',
'gitlab.addMRComment',
'gitlab.listWorkItems',
'gitlab.mergeMR',
'gitlab.todos',
'gitlab.updateIssue',
'gitlab.updateMR',
'gitlab.updateMRState',
'gitlab.workItemDetails',
'linear.getIssue',
'linear.addIssueComment',
'linear.connect',
'linear.createIssue',
'linear.issueComments',
'linear.listIssues',
'linear.listProjects',
'linear.teamLabels',
'linear.teamMembers',
'linear.listTeams',
'linear.searchIssues',
'linear.selectWorkspace',
'linear.status',
'linear.teamStates',
'linear.updateIssue',
'markdown.readTab',
'markdown.saveTab',
'notifications.subscribe',
'notifications.unsubscribe',
'preflight.check',
'preflight.detectAgents',
'preflight.detectRemoteAgents',
'repo.hooks',
'repo.list',
'repo.saveSparsePreset',
'repo.searchRefs',
'repo.sparsePresets',
'repo.update',
'session.tabs.activate',
'session.tabs.close',
'session.tabs.createTerminal',
@@ -171,6 +241,14 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'session.tabs.subscribe',
'session.tabs.subscribeAll',
'session.tabs.unsubscribe',
'settings.get',
'settings.update',
'ssh.connect',
'ssh.getState',
'speech.dictation.cancel',
'speech.dictation.chunk',
'speech.dictation.finish',
'speech.dictation.start',
'stats.summary',
'status.get',
'terminal.clearBuffer',
@@ -193,6 +271,8 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'worktree.activate',
'worktree.create',
'worktree.ps',
'worktree.resolveMrBase',
'worktree.resolvePrBase',
'worktree.rm',
'worktree.set',
'worktree.sleep'
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: hosted review creation permutations share large mocks; splitting would hide branch-specific expectations. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
@@ -10,7 +11,8 @@ const {
getHostedReviewForBranchMock,
ghExecFileAsyncMock,
gitExecFileAsyncMock,
getUpstreamStatusMock
getUpstreamStatusMock,
getSshGitProviderMock
} = vi.hoisted(() => ({
createGitHubPullRequestMock: vi.fn(),
getRepoSlugMock: vi.fn(),
@@ -21,7 +23,8 @@ const {
getHostedReviewForBranchMock: vi.fn(),
ghExecFileAsyncMock: vi.fn(),
gitExecFileAsyncMock: vi.fn(),
getUpstreamStatusMock: vi.fn()
getUpstreamStatusMock: vi.fn(),
getSshGitProviderMock: vi.fn()
}))
vi.mock('../github/client', () => ({
@@ -56,6 +59,10 @@ vi.mock('../git/upstream', () => ({
getUpstreamStatus: getUpstreamStatusMock
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock
}))
vi.mock('./hosted-review', () => ({
getHostedReviewForBranch: getHostedReviewForBranchMock
}))
@@ -73,7 +80,8 @@ function resetMocks(): void {
getHostedReviewForBranchMock,
ghExecFileAsyncMock,
gitExecFileAsyncMock,
getUpstreamStatusMock
getUpstreamStatusMock,
getSshGitProviderMock
]) {
mock.mockReset()
}
@@ -184,6 +192,71 @@ describe('createHostedReview', () => {
expect(createGitHubPullRequestMock).toHaveBeenCalledOnce()
})
it('uses the SSH git provider for remote hosted-review preflight', async () => {
const remoteGit = {
exec: vi.fn(async (args: string[]) => {
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') {
return { stdout: 'feature\n', stderr: '' }
}
if (args[0] === 'status') {
return { stdout: '', stderr: '' }
}
if (args[0] === 'rev-parse' && args[2] === 'HEAD@{u}') {
return { stdout: 'origin/feature\n', stderr: '' }
}
if (args[0] === 'rev-list') {
return { stdout: '0 0\n', stderr: '' }
}
if (args[0] === 'log' && args.includes('--pretty=%s')) {
return { stdout: 'Feature title\n', stderr: '' }
}
if (args[0] === 'log') {
return { stdout: '- Feature title\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
}
getSshGitProviderMock.mockReturnValue(remoteGit)
await expect(
createHostedReview(
'/remote/repo',
{
provider: 'github',
base: 'main',
head: 'feature',
title: 'Feature'
},
'ssh-1'
)
).resolves.toEqual({
ok: true,
number: 12,
url: 'https://github.com/acme/orca/pull/12'
})
expect(remoteGit.exec).toHaveBeenCalledWith(
['rev-parse', '--abbrev-ref', 'HEAD'],
'/remote/repo'
)
expect(remoteGit.exec).toHaveBeenCalledWith(['status', '--porcelain'], '/remote/repo')
expect(getUpstreamStatusMock).not.toHaveBeenCalled()
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
['auth', 'status', '--hostname', 'github.com'],
{}
)
expect(createGitHubPullRequestMock).toHaveBeenCalledWith(
'/remote/repo',
{
provider: 'github',
base: 'main',
head: 'feature',
title: 'Feature'
},
'ssh-1'
)
})
it('returns the existing review instead of creating a duplicate', async () => {
getHostedReviewForBranchMock.mockResolvedValue({
provider: 'github',
@@ -287,6 +360,46 @@ describe('getHostedReviewCreationEligibility', () => {
})
})
it('resolves remote eligibility through SSH repo metadata', async () => {
const remoteGit = {
exec: vi.fn(async (args: string[]) => {
if (args[0] === 'log' && args.includes('--pretty=%s')) {
return { stdout: 'Remote title\n', stderr: '' }
}
if (args[0] === 'log') {
return { stdout: '- Remote title\n', stderr: '' }
}
return { stdout: '', stderr: '' }
})
}
getSshGitProviderMock.mockReturnValue(remoteGit)
await expect(
getHostedReviewCreationEligibility({
repoPath: '/remote/repo',
connectionId: 'ssh-1',
branch: 'feature/create-pr',
base: 'origin/main',
hasUncommittedChanges: false,
hasUpstream: true,
ahead: 0,
behind: 0
})
).resolves.toMatchObject({
provider: 'github',
canCreate: true,
title: 'Remote title',
body: '- Remote title'
})
expect(getProjectSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1')
expect(getRepoSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1')
expect(getHostedReviewForBranchMock).toHaveBeenCalledWith(
expect.objectContaining({ repoPath: '/remote/repo', connectionId: 'ssh-1' })
)
expect(remoteGit.exec).toHaveBeenCalledWith(['log', '-1', '--pretty=%s'], '/remote/repo')
})
it('offers push as the next action for authenticated branches with local-only commits', async () => {
await expect(
getHostedReviewCreationEligibility({
+140 -32
View File
@@ -17,12 +17,19 @@ import { getBitbucketRepoSlug } from '../bitbucket/client'
import { getGiteaRepoSlug } from '../gitea/client'
import { createGitHubPullRequest, getRepoSlug } from '../github/client'
import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils'
import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error'
import type { GitUpstreamStatus } from '../../shared/types'
import { gitOptionalLocksDisabledEnv } from '../git/runner'
import { resolveDefaultBaseRefViaExec } from '../git/repo'
import { getUpstreamStatus } from '../git/upstream'
import { getProjectSlug } from '../gitlab/client'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import { getHostedReviewForBranch } from './hosted-review'
type HostedReviewCreationEligibilityInput = HostedReviewCreationEligibilityArgs & {
connectionId?: string | null
}
function stripRefPrefix(ref: string): string {
return normalizeHostedReviewHeadRef(ref)
}
@@ -36,11 +43,14 @@ function branchToTitle(branch: string): string {
.replace(/\b\w/g, (char) => char.toUpperCase())
}
async function detectHostedReviewProvider(repoPath: string): Promise<HostedReviewProvider> {
if (await getProjectSlug(repoPath)) {
async function detectHostedReviewProvider(
repoPath: string,
connectionId?: string | null
): Promise<HostedReviewProvider> {
if (await getProjectSlug(repoPath, connectionId)) {
return 'gitlab'
}
if (await getRepoSlug(repoPath)) {
if (await getRepoSlug(repoPath, connectionId)) {
return 'github'
}
if (await getBitbucketRepoSlug(repoPath)) {
@@ -55,10 +65,16 @@ async function detectHostedReviewProvider(repoPath: string): Promise<HostedRevie
return 'unsupported'
}
async function isGitHubAuthenticated(repoPath: string): Promise<boolean> {
async function isGitHubAuthenticated(
repoPath: string,
connectionId?: string | null
): Promise<boolean> {
await acquire()
try {
await ghExecFileAsync(['auth', 'status', '--hostname', 'github.com'], { cwd: repoPath })
await ghExecFileAsync(
['auth', 'status', '--hostname', 'github.com'],
connectionId ? {} : { cwd: repoPath }
)
return true
} catch {
return false
@@ -67,9 +83,33 @@ async function isGitHubAuthenticated(repoPath: string): Promise<boolean> {
}
}
async function getLatestCommitSubject(repoPath: string): Promise<string | null> {
async function runGitForHostedReview(
repoPath: string,
args: string[],
connectionId?: string | null
): Promise<{ stdout: string; stderr?: string }> {
if (connectionId) {
const provider = getSshGitProvider(connectionId)
if (!provider) {
throw new Error(
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
)
}
return provider.exec(args, repoPath)
}
return gitExecFileAsync(args, { cwd: repoPath })
}
async function getLatestCommitSubject(
repoPath: string,
connectionId?: string | null
): Promise<string | null> {
try {
const { stdout } = await gitExecFileAsync(['log', '-1', '--pretty=%s'], { cwd: repoPath })
const { stdout } = await runGitForHostedReview(
repoPath,
['log', '-1', '--pretty=%s'],
connectionId
)
const subject = stdout.trim()
return subject || null
} catch {
@@ -77,14 +117,19 @@ async function getLatestCommitSubject(repoPath: string): Promise<string | null>
}
}
async function getCommitSummaryBody(repoPath: string, base: string | null): Promise<string | null> {
async function getCommitSummaryBody(
repoPath: string,
base: string | null,
connectionId?: string | null
): Promise<string | null> {
if (!base) {
return null
}
try {
const { stdout } = await gitExecFileAsync(
const { stdout } = await runGitForHostedReview(
repoPath,
['log', '--pretty=format:- %s', '--max-count=20', `${base}..HEAD`],
{ cwd: repoPath }
connectionId
)
const body = stdout.trim()
return body || null
@@ -93,18 +138,34 @@ async function getCommitSummaryBody(repoPath: string, base: string | null): Prom
}
}
async function getDefaultBaseRef(repoPath: string): Promise<string | null> {
return resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, { cwd: repoPath }))
async function getDefaultBaseRef(
repoPath: string,
connectionId?: string | null
): Promise<string | null> {
return resolveDefaultBaseRefViaExec((argv) => runGitForHostedReview(repoPath, argv, connectionId))
}
async function getCurrentBranch(repoPath: string): Promise<string> {
const { stdout } = await gitExecFileAsync(['rev-parse', '--abbrev-ref', 'HEAD'], {
cwd: repoPath
})
async function getCurrentBranch(repoPath: string, connectionId?: string | null): Promise<string> {
const { stdout } = await runGitForHostedReview(
repoPath,
['rev-parse', '--abbrev-ref', 'HEAD'],
connectionId
)
return stripRefPrefix(stdout.trim())
}
async function hasUncommittedChanges(repoPath: string): Promise<boolean> {
async function hasUncommittedChanges(
repoPath: string,
connectionId?: string | null
): Promise<boolean> {
if (connectionId) {
const { stdout } = await runGitForHostedReview(
repoPath,
['status', '--porcelain'],
connectionId
)
return stdout.trim().length > 0
}
const { stdout } = await gitExecFileAsync(['status', '--porcelain'], {
cwd: repoPath,
// Why: create-PR validation should not take Git's optional index lock while
@@ -114,6 +175,47 @@ async function hasUncommittedChanges(repoPath: string): Promise<boolean> {
return stdout.trim().length > 0
}
async function getHostedReviewUpstreamStatus(
repoPath: string,
connectionId?: string | null
): Promise<GitUpstreamStatus> {
if (!connectionId) {
return getUpstreamStatus(repoPath)
}
try {
const { stdout: upstreamStdout } = await runGitForHostedReview(
repoPath,
['rev-parse', '--abbrev-ref', 'HEAD@{u}'],
connectionId
)
const upstreamName = upstreamStdout.trim()
if (!upstreamName) {
return { hasUpstream: false, ahead: 0, behind: 0 }
}
const { stdout: countsStdout } = await runGitForHostedReview(
repoPath,
['rev-list', '--left-right', '--count', 'HEAD...@{u}'],
connectionId
)
const tokens = countsStdout.trim().split(/\s+/)
if (tokens.length !== 2) {
throw new Error(`Unexpected git rev-list output: ${JSON.stringify(countsStdout)}`)
}
const ahead = Number.parseInt(tokens[0]!, 10)
const behind = Number.parseInt(tokens[1]!, 10)
if (!Number.isFinite(ahead) || !Number.isFinite(behind) || ahead < 0 || behind < 0) {
throw new Error(`Unparseable git rev-list counts: ${JSON.stringify(countsStdout)}`)
}
return { hasUpstream: true, upstreamName, ahead, behind }
} catch (error) {
if (isNoUpstreamError(error)) {
return { hasUpstream: false, ahead: 0, behind: 0 }
}
throw new Error(normalizeGitErrorMessage(error, 'upstream'))
}
}
const blockedCreateResultByReason = {
auth_required: {
ok: false,
@@ -191,10 +293,11 @@ function blockedEligibilityToCreateResult(
async function validateCurrentBranchCanCreateReview(
repoPath: string,
connectionId: string | null | undefined,
input: CreateHostedReviewInput
): Promise<CreateHostedReviewResult | null> {
const requestedHead = input.head ? stripRefPrefix(input.head).trim() : ''
const currentBranch = await getCurrentBranch(repoPath)
const currentBranch = await getCurrentBranch(repoPath, connectionId)
if (requestedHead && requestedHead !== currentBranch) {
return {
ok: false,
@@ -205,8 +308,8 @@ async function validateCurrentBranchCanCreateReview(
try {
const [dirty, upstreamStatus] = await Promise.all([
hasUncommittedChanges(repoPath),
getUpstreamStatus(repoPath)
hasUncommittedChanges(repoPath, connectionId),
getHostedReviewUpstreamStatus(repoPath, connectionId)
])
const eligibility = await getHostedReviewCreationEligibility({
repoPath,
@@ -215,7 +318,8 @@ async function validateCurrentBranchCanCreateReview(
hasUncommittedChanges: dirty,
hasUpstream: upstreamStatus.hasUpstream,
ahead: upstreamStatus.ahead,
behind: upstreamStatus.behind
behind: upstreamStatus.behind,
connectionId
})
// Why: renderer eligibility can be stale by submit time; the main process
// is the last chance to avoid creating a PR from an out-of-date remote head.
@@ -232,11 +336,12 @@ async function validateCurrentBranchCanCreateReview(
}
export async function getHostedReviewCreationEligibility(
args: HostedReviewCreationEligibilityArgs
args: HostedReviewCreationEligibilityInput
): Promise<HostedReviewCreationEligibility> {
const branch = stripRefPrefix(args.branch).trim()
const provider = await detectHostedReviewProvider(args.repoPath)
const defaultBaseRef = args.base?.trim() || (await getDefaultBaseRef(args.repoPath))
const provider = await detectHostedReviewProvider(args.repoPath, args.connectionId)
const defaultBaseRef =
args.base?.trim() || (await getDefaultBaseRef(args.repoPath, args.connectionId))
const baseBranch = defaultBaseRef ? normalizeHostedReviewBaseRef(defaultBaseRef) : null
const review = await getHostedReviewForBranch({
repoPath: args.repoPath,
@@ -246,11 +351,13 @@ export async function getHostedReviewCreationEligibility(
linkedGitLabMR: args.linkedGitLabMR ?? null,
linkedBitbucketPR: args.linkedBitbucketPR ?? null,
linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null,
linkedGiteaPR: args.linkedGiteaPR ?? null
linkedGiteaPR: args.linkedGiteaPR ?? null,
connectionId: args.connectionId ?? null
})
const title = (await getLatestCommitSubject(args.repoPath)) ?? branchToTitle(branch)
const body = await getCommitSummaryBody(args.repoPath, defaultBaseRef ?? null)
const title =
(await getLatestCommitSubject(args.repoPath, args.connectionId)) ?? branchToTitle(branch)
const body = await getCommitSummaryBody(args.repoPath, defaultBaseRef ?? null, args.connectionId)
const baseResult = {
provider,
review: review ? { number: review.number, url: review.url } : null,
@@ -294,7 +401,7 @@ export async function getHostedReviewCreationEligibility(
if ((args.behind ?? 0) > 0) {
return { ...baseResult, canCreate: false, blockedReason: 'needs_sync', nextAction: 'sync' }
}
if (!(await isGitHubAuthenticated(args.repoPath))) {
if (!(await isGitHubAuthenticated(args.repoPath, args.connectionId))) {
return {
...baseResult,
canCreate: false,
@@ -310,7 +417,8 @@ export async function getHostedReviewCreationEligibility(
export async function createHostedReview(
repoPath: string,
input: CreateHostedReviewInput
input: CreateHostedReviewInput,
connectionId?: string | null
): Promise<CreateHostedReviewResult> {
if (input.provider !== 'github') {
return {
@@ -319,7 +427,7 @@ export async function createHostedReview(
error: 'Creating reviews for this provider is not supported yet.'
}
}
const provider = await detectHostedReviewProvider(repoPath)
const provider = await detectHostedReviewProvider(repoPath, connectionId)
if (provider !== 'github') {
return {
ok: false,
@@ -327,9 +435,9 @@ export async function createHostedReview(
error: 'Creating pull requests requires a GitHub remote.'
}
}
const blocked = await validateCurrentBranchCanCreateReview(repoPath, input)
const blocked = await validateCurrentBranchCanCreateReview(repoPath, connectionId, input)
if (blocked) {
return blocked
}
return createGitHubPullRequest(repoPath, input)
return createGitHubPullRequest(repoPath, input, connectionId)
}
@@ -82,7 +82,11 @@ describe('getHostedReviewForBranch', () => {
})
await expect(
getHostedReviewForBranch({ repoPath: '/repo', branch: 'refs/heads/feature' })
getHostedReviewForBranch({
repoPath: '/repo',
connectionId: 'ssh-1',
branch: 'refs/heads/feature'
})
).resolves.toEqual({
provider: 'gitlab',
number: 7,
@@ -93,6 +97,8 @@ describe('getHostedReviewForBranch', () => {
updatedAt: '2026-05-10T00:00:00.000Z',
mergeable: 'MERGEABLE'
})
expect(getProjectSlugMock).toHaveBeenCalledWith('/repo', 'ssh-1')
expect(getMergeRequestForBranchMock).toHaveBeenCalledWith('/repo', 'feature', null, 'ssh-1')
expect(getPRForBranchMock).not.toHaveBeenCalled()
})
+7 -3
View File
@@ -119,11 +119,15 @@ export async function getHostedReviewForBranch(input: {
// Why: branch review status is tied to the branch publishing remote.
// GitHub and GitLab task/project surfaces may use richer per-provider
// source preferences, but this core status should follow origin.
const gitlabProject = await getProjectSlug(input.repoPath)
const gitlabProject = await getProjectSlug(input.repoPath, input.connectionId)
if (gitlabProject) {
const mr =
(await getMergeRequestForBranch(input.repoPath, branchName, input.linkedGitLabMR ?? null)) ??
null
(await getMergeRequestForBranch(
input.repoPath,
branchName,
input.linkedGitLabMR ?? null,
input.connectionId
)) ?? null
return mr ? mapGitLabReview(mr) : null
}
+1 -1
View File
@@ -683,7 +683,7 @@ export type PreloadApi = {
mrIid: number
sourceBranch?: string
isCrossRepository?: boolean
}) => Promise<{ baseBranch: string } | { error: string }>
}) => Promise<{ baseBranch: string; pushTarget?: GitPushTarget } | { error: string }>
remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => Promise<void>
updateMeta: (args: { worktreeId: string; updates: Partial<WorktreeMeta> }) => Promise<Worktree>
listLineage: () => Promise<Record<string, WorktreeLineage>>
+1 -1
View File
@@ -497,7 +497,7 @@ const api = {
mrIid: number
sourceBranch?: string
isCrossRepository?: boolean
}): Promise<{ baseBranch: string } | { error: string }> =>
}): Promise<{ baseBranch: string; pushTarget?: unknown } | { error: string }> =>
ipcRenderer.invoke('worktrees:resolveMrBase', args),
remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }): Promise<void> =>
+4
View File
@@ -17,6 +17,7 @@ export async function addWorktreeOp(git: GitExec, params: Record<string, unknown
const targetDir = params.targetDir as string
const base = params.base as string | undefined
const checkoutExistingBranch = params.checkoutExistingBranch === true
const noCheckout = params.noCheckout === true
// Why: a branchName starting with '-' would be interpreted as a git flag,
// potentially changing the command's semantics (e.g. "--detach").
@@ -47,6 +48,9 @@ export async function addWorktreeOp(git: GitExec, params: Record<string, unknown
const args = checkoutExistingBranch
? ['worktree', 'add', targetDir, branchName]
: ['worktree', 'add', '--no-track', '-b', branchName, targetDir]
if (!checkoutExistingBranch && noCheckout) {
args.splice(3, 0, '--no-checkout')
}
if (effectiveBase) {
args.push(effectiveBase)
}
+27
View File
@@ -865,6 +865,33 @@ describe('GitHandler', () => {
])
})
it('passes --no-checkout when sparse setup will checkout after configuration', async () => {
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // rev-parse refs/remotes/origin/main
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // worktree add
gitMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // --get
gitMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) // --local set
await localDispatcher.callRequest('git.addWorktree', {
repoPath: '/relay/repo',
branchName: 'feature/sparse',
targetDir: '/relay/wt',
base: 'origin/main',
noCheckout: true
})
expect(gitMock.mock.calls[1]?.[0]).toEqual([
'worktree',
'add',
'--no-track',
'--no-checkout',
'-b',
'feature/sparse',
'/relay/wt',
'refs/remotes/origin/main'
])
})
it('preserves an existing push.autoSetupRemote value (does not overwrite user-set false)', async () => {
const { localDispatcher, gitMock } = setupMockedHandler(['/relay/repo', '/relay/wt'])
gitMock.mockRejectedValueOnce(new Error('not a branch')) // rev-parse refs/heads/main^{commit}
@@ -463,7 +463,7 @@ export default function GitLabItemDialog({
className="gap-1.5"
>
<ExternalLink className="size-3.5" />
Open in browser
Open in GitLab
</Button>
<div className="flex items-center gap-2">
{onCreateWorkspace ? (
+5 -5
View File
@@ -1848,7 +1848,7 @@ function PaginationBar({
}): React.JSX.Element {
const pageNumbers = getPageNumbers(currentPage, totalPages)
const btnClass =
'inline-flex items-center gap-0.5 rounded-md px-2 py-1 text-sm text-muted-foreground transition hover:bg-muted/60 hover:text-foreground disabled:pointer-events-none disabled:opacity-40'
'inline-flex w-24 items-center justify-center gap-0.5 rounded-md px-2 py-1 text-sm text-muted-foreground transition hover:bg-muted/60 hover:text-foreground disabled:pointer-events-none disabled:opacity-40'
const numClass = (page: number): string =>
cn(
'inline-flex size-8 items-center justify-center rounded-md text-sm transition',
@@ -2673,9 +2673,9 @@ export default function TaskPage(): React.JSX.Element {
) {
return
}
// Why: GitLab queries don't work over SSH-relay (yet) and folder-
// mode repos have no remotes to derive a project from. Filter both.
const eligibleRepos = selectedRepos.filter((r) => !r.connectionId)
// Why: folder-mode repos have no remotes to derive a GitLab project from;
// SSH-backed Git repos go through the same provider-aware IPC path.
const eligibleRepos = selectedRepos
if (eligibleRepos.length === 0) {
setGitlabItems([])
setGitlabLoading(false)
@@ -5343,7 +5343,7 @@ export default function TaskPage(): React.JSX.Element {
e.stopPropagation()
void window.api.shell.openUrl(item.url)
}}
aria-label="Open in browser"
aria-label="Open in GitLab"
className="text-muted-foreground hover:text-foreground"
>
<ExternalLink className="size-3.5" />
@@ -1,7 +1,7 @@
import React from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { isIterationCurrent, type ProjectGroup } from './group-sort'
import { isIterationCurrent, type ProjectGroup } from '../../../../shared/github-project-group-sort'
type Props = {
group: ProjectGroup

Some files were not shown because too many files have changed in this diff Show More