mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
Generated
-26
@@ -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==}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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('')).toEqual([
|
||||
{
|
||||
type: 'image',
|
||||
alt: 'Screenshot',
|
||||
url: 'https://example.com/screen.png'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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.'
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
],
|
||||
{}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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` ────────────────────
|
||||
|
||||
@@ -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
@@ -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[] = []
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
+1187
-108
File diff suppressed because it is too large
Load Diff
@@ -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': {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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' } }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } })
|
||||
})
|
||||
})
|
||||
@@ -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) })
|
||||
})
|
||||
]
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user