diff --git a/.gitignore b/.gitignore index e0acc0dc..a709d5ad 100644 --- a/.gitignore +++ b/.gitignore @@ -107,7 +107,7 @@ dmypy.json # =========================== # Node.js (apps/frontend) # =========================== -node_modules/ +node_modules .npm .yarn/ .pnp.* diff --git a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts index cbd45da5..bc3a6942 100644 --- a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts @@ -2329,6 +2329,65 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v } ); + // Update PR branch (sync with base branch) + ipcMain.handle( + IPC_CHANNELS.GITHUB_PR_UPDATE_BRANCH, + async (_, projectId: string, prNumber: number): Promise<{ success: boolean; error?: string }> => { + debugLog("updateBranch handler called", { projectId, prNumber }); + + const updateResult = await withProjectOrNull(projectId, async (project) => { + try { + const { execFile } = await import("child_process"); + const { promisify } = await import("util"); + const execFileAsync = promisify(execFile); + debugLog("Updating PR branch", { prNumber }); + + // Validate prNumber to prevent command injection + if (!Number.isInteger(prNumber) || prNumber <= 0) { + throw new Error("Invalid PR number"); + } + + // Use gh pr update-branch to sync with base branch (async to avoid blocking main process) + // --rebase is not used to avoid force-push requirements + await execFileAsync("gh", ["pr", "update-branch", String(prNumber)], { + cwd: project.path, + env: getAugmentedEnv(), + }); + + debugLog("PR branch updated successfully", { prNumber }); + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + debugLog("Failed to update PR branch", { prNumber, error: errorMessage }); + + // Map common error patterns to user-friendly messages + let friendlyError = errorMessage; + if (errorMessage.includes("permission") || errorMessage.includes("403")) { + friendlyError = "You don't have permission to update this branch."; + } else if (errorMessage.includes("401") || errorMessage.toLowerCase().includes("auth") || errorMessage.toLowerCase().includes("token")) { + friendlyError = "Authentication failed. Try running 'gh auth login' to re-authenticate."; + } else if (errorMessage.includes("404") || errorMessage.includes("not found")) { + friendlyError = "Pull request not found. It may have been closed or deleted."; + } else if (errorMessage.includes("429") || errorMessage.toLowerCase().includes("rate limit")) { + friendlyError = "GitHub API rate limit exceeded. Please wait and try again."; + } else if (errorMessage.includes("conflict")) { + friendlyError = "Cannot update branch due to merge conflicts. Resolve conflicts manually."; + } else if (errorMessage.toLowerCase().includes("protected") || errorMessage.toLowerCase().includes("branch protection")) { + friendlyError = "Branch protection rules prevent this update."; + } else if (errorMessage.includes("ENOTFOUND") || errorMessage.includes("ECONNREFUSED") || errorMessage.includes("ETIMEDOUT")) { + friendlyError = "Network error. Check your internet connection and try again."; + } else if (errorMessage.toLowerCase().includes("already up to date")) { + return { success: true }; // Not an error + } + + return { success: false, error: friendlyError }; + } + }); + + return updateResult ?? { success: false, error: "Project not found" }; + } + ); + // Run follow-up review ipcMain.on( IPC_CHANNELS.GITHUB_PR_FOLLOWUP_REVIEW, diff --git a/apps/frontend/src/preload/api/modules/github-api.ts b/apps/frontend/src/preload/api/modules/github-api.ts index 6408479f..f7ec9bf7 100644 --- a/apps/frontend/src/preload/api/modules/github-api.ts +++ b/apps/frontend/src/preload/api/modules/github-api.ts @@ -278,6 +278,7 @@ export interface GitHubAPI { // Follow-up review operations checkNewCommits: (projectId: string, prNumber: number) => Promise; checkMergeReadiness: (projectId: string, prNumber: number) => Promise; + updatePRBranch: (projectId: string, prNumber: number) => Promise<{ success: boolean; error?: string }>; runFollowupReview: (projectId: string, prNumber: number) => void; // PR logs @@ -690,6 +691,9 @@ export const createGitHubAPI = (): GitHubAPI => ({ checkMergeReadiness: (projectId: string, prNumber: number): Promise => invokeIpc(IPC_CHANNELS.GITHUB_PR_CHECK_MERGE_READINESS, projectId, prNumber), + updatePRBranch: (projectId: string, prNumber: number): Promise<{ success: boolean; error?: string }> => + invokeIpc(IPC_CHANNELS.GITHUB_PR_UPDATE_BRANCH, projectId, prNumber), + runFollowupReview: (projectId: string, prNumber: number): void => sendIpc(IPC_CHANNELS.GITHUB_PR_FOLLOWUP_REVIEW, projectId, prNumber), diff --git a/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx b/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx index 5c85d474..2a9e0a1f 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx @@ -5,6 +5,7 @@ import { Send, XCircle, Loader2, + GitBranch, GitMerge, CheckCircle, RefreshCw, @@ -121,6 +122,12 @@ export function PRDetail({ const [mergeReadiness, setMergeReadiness] = useState(null); const mergeReadinessAbortRef = useRef(null); + // Branch update state (for updating PR branch when behind base) + const [isUpdatingBranch, setIsUpdatingBranch] = useState(false); + const [branchUpdateError, setBranchUpdateError] = useState(null); + const [branchUpdateSuccess, setBranchUpdateSuccess] = useState(false); + const [mergeReadinessRefreshKey, setMergeReadinessRefreshKey] = useState(0); + // Workflows awaiting approval state (for fork PRs) const [workflowsAwaiting, setWorkflowsAwaiting] = useState(null); const [isApprovingWorkflow, setIsApprovingWorkflow] = useState(null); @@ -208,6 +215,14 @@ export function PRDetail({ } }, [postSuccess]); + // Clear branch update success message after 3 seconds + useEffect(() => { + if (branchUpdateSuccess) { + const timer = setTimeout(() => setBranchUpdateSuccess(false), 3000); + return () => clearTimeout(timer); + } + }, [branchUpdateSuccess]); + // Auto-expand logs section when review starts useEffect(() => { if (isReviewing) { @@ -278,6 +293,10 @@ export function PRDetail({ setBlockedStatusPosted(false); setBlockedStatusError(null); setIsPostingBlockedStatus(false); + // Reset branch update state as well + setBranchUpdateError(null); + setBranchUpdateSuccess(false); + setIsUpdatingBranch(false); }, [pr.number]); // Check for workflows awaiting approval (fork PRs) when PR changes or review completes @@ -333,7 +352,7 @@ export function PRDetail({ mergeReadinessAbortRef.current.abort(); } }; - }, [pr.number, projectId]); + }, [pr.number, projectId, mergeReadinessRefreshKey]); // Handler to approve a workflow const handleApproveWorkflow = useCallback(async (runId: number) => { @@ -369,6 +388,40 @@ export function PRDetail({ setWorkflowsAwaiting(result); }, [pr.number, workflowsAwaiting]); + // Handler to update PR branch when behind base + const handleUpdateBranch = useCallback(async () => { + // Capture current PR number to prevent state leaks across PR switches + const currentPr = pr.number; + + setIsUpdatingBranch(true); + setBranchUpdateError(null); + setBranchUpdateSuccess(false); + + try { + const result = await window.electronAPI.github.updatePRBranch(projectId, pr.number); + + // Only update state if PR hasn't changed + if (pr.number === currentPr) { + if (result.success) { + setBranchUpdateSuccess(true); + // Trigger merge readiness refresh to update the UI + setMergeReadinessRefreshKey(prev => prev + 1); + } else { + setBranchUpdateError(result.error || t('prReview.branchUpdateFailed')); + } + } + } catch (err) { + if (pr.number === currentPr) { + const errorMessage = err instanceof Error ? err.message : String(err); + setBranchUpdateError(errorMessage); + } + } finally { + if (pr.number === currentPr) { + setIsUpdatingBranch(false); + } + } + }, [pr.number, projectId, t]); + // Count selected findings by type for the button label const selectedCount = selectedFindingIds.size; @@ -765,6 +818,29 @@ ${t('prReview.blockedStatusMessageFooter')}`; ))} + {mergeReadiness.isBehind && ( +
+ +
+ )}

{t('prReview.rerunReviewSuggestion', 'Consider re-running the review after resolving these issues.')}

@@ -774,6 +850,18 @@ ${t('prReview.blockedStatusMessageFooter')}`; )} + {branchUpdateSuccess && ( +
+ + {t('prReview.branchUpdated')} +
+ )} + {branchUpdateError && ( +
+ {branchUpdateError} +
+ )} + {/* Review Status & Actions */} true, checkNewCommits: async () => ({ hasNewCommits: false, newCommitCount: 0 }), checkMergeReadiness: async () => ({ isDraft: false, mergeable: 'UNKNOWN' as const, isBehind: false, ciStatus: 'none' as const, blockers: [] }), + updatePRBranch: async () => ({ success: true }), runFollowupReview: () => {}, getPRLogs: async () => null, getWorkflowsAwaitingApproval: async () => ({ awaiting_approval: 0, workflow_runs: [], can_approve: false }), diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index 9e4f5b36..45181c98 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -374,6 +374,7 @@ export const IPC_CHANNELS = { GITHUB_PR_FOLLOWUP_REVIEW: 'github:pr:followupReview', GITHUB_PR_CHECK_NEW_COMMITS: 'github:pr:checkNewCommits', GITHUB_PR_CHECK_MERGE_READINESS: 'github:pr:checkMergeReadiness', + GITHUB_PR_UPDATE_BRANCH: 'github:pr:updateBranch', // GitHub PR Review events (main -> renderer) GITHUB_PR_REVIEW_PROGRESS: 'github:pr:reviewProgress', diff --git a/apps/frontend/src/shared/i18n/locales/en/common.json b/apps/frontend/src/shared/i18n/locales/en/common.json index bb2d0a2c..c5f80038 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -320,6 +320,10 @@ "initial": "Initial", "rerunFollowup": "Re-run follow-up review", "rerunReview": "Re-run review", + "updateBranch": "Update Branch", + "updatingBranch": "Updating...", + "branchUpdated": "Branch updated", + "branchUpdateFailed": "Failed to update branch", "loadingMore": "Loading more PRs...", "scrollForMore": "Scroll for more", "allPRsLoaded": "All PRs loaded", diff --git a/apps/frontend/src/shared/i18n/locales/fr/common.json b/apps/frontend/src/shared/i18n/locales/fr/common.json index b704f75e..18328114 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -329,6 +329,10 @@ "initial": "Initial", "rerunFollowup": "Relancer la revue de suivi", "rerunReview": "Relancer la revue", + "updateBranch": "Mettre à jour la branche", + "updatingBranch": "Mise à jour...", + "branchUpdated": "Branche mise à jour", + "branchUpdateFailed": "Échec de la mise à jour de la branche", "loadingMore": "Chargement des PRs...", "scrollForMore": "Défiler pour plus", "allPRsLoaded": "Tous les PRs chargés",