Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,15 @@ describe('Git IPC Handlers - Remote Pull Request', () => {
// 5. gh pr view
.mockResolvedValueOnce({
exitCode: 0,
stdout: JSON.stringify({ number: 123, url: 'https://github.com/BohuTANG/blog-hexo/pull/123', state: 'OPEN' }),
stdout: JSON.stringify({ number: 123, url: 'https://github.com/BohuTANG/blog-hexo/pull/123', state: 'OPEN', isDraft: false }),
stderr: '',
} as MockRunResult);

const result = await mockIpcMain.invoke('sessions:get-remote-pull-request', sessionId);

expect(result).toEqual({
success: true,
data: { number: 123, url: 'https://github.com/BohuTANG/blog-hexo/pull/123', merged: false },
data: { number: 123, url: 'https://github.com/BohuTANG/blog-hexo/pull/123', state: 'open' },
});

// Verify gh pr view was called with --repo and branch
Expand Down Expand Up @@ -166,15 +166,15 @@ describe('Git IPC Handlers - Remote Pull Request', () => {
// 5. gh pr view
.mockResolvedValueOnce({
exitCode: 0,
stdout: JSON.stringify({ number: 42, url: 'https://github.com/owner/repo/pull/42', state: 'MERGED' }),
stdout: JSON.stringify({ number: 42, url: 'https://github.com/owner/repo/pull/42', state: 'MERGED', isDraft: false }),
stderr: '',
} as MockRunResult);

const result = await mockIpcMain.invoke('sessions:get-remote-pull-request', sessionId);

expect(result).toEqual({
success: true,
data: { number: 42, url: 'https://github.com/owner/repo/pull/42', merged: true },
data: { number: 42, url: 'https://github.com/owner/repo/pull/42', state: 'merged' },
});

// Verify --repo contains owner/repo from HTTPS URL
Expand Down Expand Up @@ -316,15 +316,15 @@ describe('Git IPC Handlers - Remote Pull Request', () => {
// 5. gh pr view
.mockResolvedValueOnce({
exitCode: 0,
stdout: JSON.stringify({ number: 50, url: 'https://github.com/owner/repo/pull/50', state: 'MERGED' }),
stdout: JSON.stringify({ number: 50, url: 'https://github.com/owner/repo/pull/50', state: 'MERGED', isDraft: false }),
stderr: '',
} as MockRunResult);

const result = await mockIpcMain.invoke('sessions:get-remote-pull-request', sessionId);

expect(result).toEqual({
success: true,
data: { number: 50, url: 'https://github.com/owner/repo/pull/50', merged: true },
data: { number: 50, url: 'https://github.com/owner/repo/pull/50', state: 'merged' },
});
});

Expand Down Expand Up @@ -410,15 +410,15 @@ describe('Git IPC Handlers - Remote Pull Request', () => {
// 5. gh pr view
.mockResolvedValueOnce({
exitCode: 0,
stdout: JSON.stringify({ number: 10, url: 'https://github.com/owner/repo/pull/10', state: 'OPEN' }),
stdout: JSON.stringify({ number: 10, url: 'https://github.com/owner/repo/pull/10', state: 'OPEN', isDraft: false }),
stderr: '',
} as MockRunResult);

const result = await mockIpcMain.invoke('sessions:get-remote-pull-request', sessionId);

expect(result).toEqual({
success: true,
data: { number: 10, url: 'https://github.com/owner/repo/pull/10', merged: false },
data: { number: 10, url: 'https://github.com/owner/repo/pull/10', state: 'open' },
});

// Verify owner/repo was parsed correctly
Expand Down Expand Up @@ -1383,8 +1383,8 @@ describe('Git IPC Handlers - CI Status', () => {
expect(result.data).toEqual({
rollupState: 'success',
checks: [
{ id: 0, name: 'build', status: 'completed', conclusion: 'success', startedAt: '2026-01-14T05:00:00Z', completedAt: '2026-01-14T05:10:00Z', detailsUrl: 'https://github.com/test/link1' },
{ id: 1, name: 'test', status: 'completed', conclusion: 'success', startedAt: '2026-01-14T05:00:00Z', completedAt: '2026-01-14T05:12:00Z', detailsUrl: 'https://github.com/test/link2' },
{ id: 0, name: 'build', workflow: null, status: 'completed', conclusion: 'success', startedAt: '2026-01-14T05:00:00Z', completedAt: '2026-01-14T05:10:00Z', detailsUrl: 'https://github.com/test/link1' },
{ id: 1, name: 'test', workflow: null, status: 'completed', conclusion: 'success', startedAt: '2026-01-14T05:00:00Z', completedAt: '2026-01-14T05:12:00Z', detailsUrl: 'https://github.com/test/link2' },
],
totalCount: 2,
successCount: 2,
Expand Down
142 changes: 134 additions & 8 deletions packages/desktop/src/infrastructure/ipc/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { AppServices } from './types';
import { promises as fs } from 'fs';
import { join } from 'path';

type RemotePullRequest = { number: number; url: string; merged: boolean };
type RemotePullRequest = { number: number; url: string; state: 'draft' | 'open' | 'merged' };

/**
* Parse a git remote URL and return the GitHub web URL for the repository.
Expand Down Expand Up @@ -419,7 +419,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo
const res = await gitExecutor.run({
sessionId,
cwd: session.worktreePath,
argv: ['gh', 'pr', 'view', ...repoArgs, '--json', 'number,url,state'],
argv: ['gh', 'pr', 'view', ...repoArgs, '--json', 'number,url,state,isDraft'],
op: 'read',
recordTimeline: false,
throwOnError: false,
Expand All @@ -433,13 +433,23 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo
if (!raw) continue; // Try next remote

try {
const parsed = JSON.parse(raw) as { number?: unknown; url?: unknown; state?: unknown } | null;
const parsed = JSON.parse(raw) as { number?: unknown; url?: unknown; state?: unknown; isDraft?: unknown } | null;
const number = parsed && typeof parsed.number === 'number' ? parsed.number : null;
const url = parsed && typeof parsed.url === 'string' ? parsed.url : '';
const state = parsed && typeof parsed.state === 'string' ? parsed.state : '';
const merged = state === 'MERGED';
const ghState = parsed && typeof parsed.state === 'string' ? parsed.state.toUpperCase() : '';
const isDraft = parsed && typeof parsed.isDraft === 'boolean' ? parsed.isDraft : false;

let prState: 'draft' | 'open' | 'merged';
if (ghState === 'MERGED') {
prState = 'merged';
} else if (isDraft) {
prState = 'draft';
} else {
prState = 'open';
}

if (!number || !url) continue; // Try next remote
const out: RemotePullRequest = { number, url, merged };
const out: RemotePullRequest = { number, url, state: prState };
return { success: true, data: out }; // Found PR, return immediately
} catch {
continue; // Try next remote
Expand Down Expand Up @@ -1155,15 +1165,15 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo
}

// Use gh pr checks to get CI status
// gh pr checks --json returns: name, state (SUCCESS/FAILURE/PENDING/etc), startedAt, completedAt, link
// gh pr checks --json returns: name, workflow, state (SUCCESS/FAILURE/PENDING/etc), startedAt, completedAt, link
const checksRes = await gitExecutor.run({
sessionId,
cwd,
argv: [
'gh', 'pr', 'checks',
'--repo', ownerRepo,
branchRef,
'--json', 'name,state,startedAt,completedAt,link',
'--json', 'name,state,startedAt,completedAt,link,workflow',
],
op: 'read',
recordTimeline: false,
Expand All @@ -1185,6 +1195,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo
try {
const checksData = JSON.parse(raw) as Array<{
name?: string;
workflow?: string;
state?: string; // SUCCESS, FAILURE, PENDING, IN_PROGRESS, SKIPPED, etc
startedAt?: string;
completedAt?: string;
Expand All @@ -1202,6 +1213,7 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo
return {
id: idx,
name: c.name || 'Unknown',
workflow: c.workflow || null,
status,
conclusion,
startedAt: c.startedAt || null,
Expand Down Expand Up @@ -1257,6 +1269,120 @@ export function registerGitHandlers(ipcMain: IpcMain, services: AppServices): vo
return { success: false, error: error instanceof Error ? error.message : 'Failed to get CI status' };
}
});

ipcMain.handle('sessions:mark-pr-ready', async (_event, sessionId: string) => {
try {
console.log('[git.ts] Mark PR ready called for session:', sessionId);
const session = sessionManager.getSession(sessionId);
if (!session?.worktreePath) {
console.error('[git.ts] Session worktree not found');
return { success: false, error: 'Session worktree not found' };
}

// Get current branch
const branchRes = await gitExecutor.run({
sessionId,
cwd: session.worktreePath,
argv: ['git', 'branch', '--show-current'],
op: 'read',
recordTimeline: false,
throwOnError: false,
timeoutMs: 5_000,
meta: { source: 'ipc.git', operation: 'mark-pr-ready-branch' },
});

if (branchRes.exitCode !== 0 || !branchRes.stdout?.trim()) {
console.error('[git.ts] Failed to get current branch');
return { success: false, error: 'Failed to get current branch' };
}

const branch = branchRes.stdout.trim();
console.log('[git.ts] Current branch:', branch);

// Get origin owner for fork workflow
let originOwner: string | null = null;
const originRes = await gitExecutor.run({
sessionId,
cwd: session.worktreePath,
argv: ['git', 'remote', 'get-url', 'origin'],
op: 'read',
recordTimeline: false,
throwOnError: false,
timeoutMs: 3_000,
meta: { source: 'ipc.git', operation: 'mark-pr-ready-origin' },
});
if (originRes.exitCode === 0 && originRes.stdout?.trim()) {
const originUrl = originRes.stdout.trim();
const originMatch = originUrl.match(/github\.com[:/]([^/]+)\//);
if (originMatch) {
originOwner = originMatch[1];
}
}
console.log('[git.ts] Origin owner:', originOwner);

// Try to mark PR ready in multiple remotes (same logic as get-remote-pull-request)
const remoteNames = ['upstream', 'origin'];

for (const remoteName of remoteNames) {
const remoteRes = await gitExecutor.run({
sessionId,
cwd: session.worktreePath,
argv: ['git', 'remote', 'get-url', remoteName],
op: 'read',
recordTimeline: false,
throwOnError: false,
timeoutMs: 3_000,
meta: { source: 'ipc.git', operation: 'mark-pr-ready-remote' },
});

if (remoteRes.exitCode !== 0 || !remoteRes.stdout?.trim()) continue;

const url = remoteRes.stdout.trim();
// Parse: git@github.com:owner/repo.git or https://github.com/owner/repo.git
const match = url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
if (!match) continue;

const ownerRepo = match[1];

// For upstream repo in fork workflow, use "owner:branch" format
// For origin repo, use just "branch"
const branchArg = remoteName === 'upstream' && originOwner ? `${originOwner}:${branch}` : branch;

console.log(`[git.ts] Trying ${remoteName} with repo=${ownerRepo}, branch=${branchArg}`);

// Mark PR as ready for review using gh pr ready
const readyRes = await gitExecutor.run({
sessionId,
cwd: session.worktreePath,
argv: ['gh', 'pr', 'ready', '--repo', ownerRepo, branchArg],
op: 'write',
recordTimeline: true,
throwOnError: false,
timeoutMs: 30_000,
meta: { source: 'ipc.git', operation: 'mark-pr-ready' },
});

console.log('[git.ts] gh pr ready result:', { exitCode: readyRes.exitCode, stdout: readyRes.stdout, stderr: readyRes.stderr });

if (readyRes.exitCode === 0) {
console.log('[git.ts] Successfully marked PR as ready');
return { success: true };
}

// If failed, try next remote
console.log(`[git.ts] Failed on ${remoteName}, trying next remote...`);
}

// Failed on all remotes
console.error('[git.ts] Failed to mark PR as ready on all remotes');
return { success: false, error: 'Failed to mark PR as ready' };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to mark PR as ready'
};
}
});
}

// Helper: parse gh pr checks 'state' field to status and conclusion
Expand Down
2 changes: 2 additions & 0 deletions packages/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
// CI status
getCIStatus: (sessionId: string): Promise<IPCResponse> =>
ipcRenderer.invoke('sessions:get-ci-status', sessionId),
markPRReady: (sessionId: string): Promise<IPCResponse> =>
ipcRenderer.invoke('sessions:mark-pr-ready', sessionId),
// Terminal helpers
ensureTerminalPanel: (sessionId: string): Promise<IPCResponse> =>
ipcRenderer.invoke('sessions:terminal-ensure-panel', sessionId),
Expand Down
42 changes: 42 additions & 0 deletions packages/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,52 @@ import { MainLayout } from './components/layout';
import { useIPCEvents } from './hooks/useIPCEvents';
import { useWorkspaceStageSync } from './hooks/useWorkspaceStageSync';
import { ErrorDialog } from './components/ErrorDialog';
import { SettingsDialog } from './components/SettingsDialog';
import { useErrorStore } from './stores/errorStore';
import { useSettingsStore } from './stores/settingsStore';
import { useThemeStore } from './stores/themeStore';
import { useEffect } from 'react';

function getResolvedTheme(themeSetting: 'light' | 'dark' | 'system'): 'light' | 'dark' {
if (themeSetting !== 'system') return themeSetting;

if (typeof window !== 'undefined' && window.matchMedia) {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'dark';
}

export default function App() {
useIPCEvents();
useWorkspaceStageSync();
const { currentError, clearError } = useErrorStore();
const { settings } = useSettingsStore();
const { setTheme } = useThemeStore();

// Initialize theme and font size on mount
useEffect(() => {
// Apply theme
const resolvedTheme = getResolvedTheme(settings.theme);
setTheme(resolvedTheme);

// Listen for system theme changes
if (settings.theme === 'system' && window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
setTheme(e.matches ? 'dark' : 'light');
};

mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}
}, [settings.theme, setTheme]);

// Apply font size
useEffect(() => {
// Apply to body and root elements to ensure it takes effect
document.body.style.fontSize = `${settings.fontSize}px`;
document.documentElement.style.fontSize = `${settings.fontSize}px`;
}, [settings.fontSize]);

return (
<div
Expand Down Expand Up @@ -41,6 +81,8 @@ export default function App() {
command={currentError.command}
/>
)}

<SettingsDialog />
</div>
);
}
1 change: 1 addition & 0 deletions packages/ui/src/assets/claude-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/ui/src/assets/gemini-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading