fix(github): use selectedPR from hook to restore Files changed list (#822)

* fix(github): use selectedPR from hook to restore Files changed list

The hook useGitHubPRs returns a selectedPR that includes full PR details
including the files array and changedFiles count. GitHubPRs.tsx was ignoring
this and doing its own lookup in the prs array (which only contains list-view
PRs without file details). This caused the Files changed list to appear empty
in the PR detail view.

Fixes ACS-173

* fix(github): add null-safe fallbacks for PR additions/deletions counts

The GitHub API may return null for additions, deletions, and changed_files
fields in certain edge cases (e.g., draft PRs, PRs with no diff yet).
Add null-safe fallbacks (?? 0) to ensure the frontend always receives
numeric values instead of null.

Also added debug logging to inspect the raw API response for troubleshooting.

Related to ACS-173

* refactor: standardize selected item pattern across issues/PRs hooks

This addresses PR review findings about inconsistent patterns:

1. Fix UI flicker in useGitHubPRs hook
   - Don't clear previous PR details when switching PRs
   - Preserve previous details during fetch to avoid empty state

2. Add selectedIssue to useGitLabIssues hook
   - Return computed selectedIssue instead of manual lookup
   - Update GitLabIssues.tsx to use hook-provided value

3. Add selectedIssue to useGitHubIssues hook
   - Return computed selectedIssue instead of manual lookup
   - Update GitHubIssues.tsx to use hook-provided value

Related to ACS-173

* fix(pr): prevent stale data and race conditions when switching PRs

Fixes two HIGH priority issues from PR review:

1. Stale PR data when switching between PRs
   - Validate that selectedPRDetails.number matches selectedPRNumber
   - Added useMemo wrapper for consistency with other hooks
   - Previously, old PR data (with its file list) was briefly shown
     under new PR's header until fetch completed

2. Race condition for out-of-order API responses
   - Track current PR being fetched in module-level variable
   - Only update selectedPRDetails if response matches current PR
   - Prevents stale responses from overwriting newer data

Related to ACS-173

* refactor(pr): address code quality issues from PR review

Fixes 4 issues identified during PR review:

1. Replace module-level mutable variable with per-hook ref
   - Removed module-level currentFetchPRNumber variable
   - Added currentFetchPRNumberRef using useRef inside hook
   - Prevents shared state across hook instances

2. Fix fetchPRs useCallback dependency array
   - Removed setNewCommitsCheckAction from dependencies
   - Function doesn't reference it, so it wasn't needed

3. Remove async modifier from fire-and-forget functions
   - runReview and runFollowupReview don't await anything
   - Store functions return void, not Promise
   - Updated interface to reflect void return type

4. Normalize API response to camelCase in handler layer
   - Updated checkNewCommits handler comment for clarity
   - Removed defensive fallbacks and "as any" casts in hook
   - Data is now properly camelCased by the handler

Related to ACS-173

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
Co-authored-by: Alex <63423455+AlexMadera@users.noreply.github.com>
This commit is contained in:
StillKnotKnown
2026-01-08 21:17:23 +02:00
committed by GitHub
parent 204588493b
commit c623ab0018
7 changed files with 1115 additions and 824 deletions
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,13 @@
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useProjectStore } from '../stores/project-store';
import { useTaskStore } from '../stores/task-store';
import { useGitHubIssues, useGitHubInvestigation, useIssueFiltering, useAutoFix } from './github-issues/hooks';
import { useAnalyzePreview } from './github-issues/hooks/useAnalyzePreview';
import { useState, useCallback, useMemo, useEffect } from "react";
import { useProjectStore } from "../stores/project-store";
import { useTaskStore } from "../stores/task-store";
import {
useGitHubIssues,
useGitHubInvestigation,
useIssueFiltering,
useAutoFix,
} from "./github-issues/hooks";
import { useAnalyzePreview } from "./github-issues/hooks/useAnalyzePreview";
import {
NotConnectedState,
EmptyState,
@@ -10,11 +15,11 @@ import {
IssueList,
IssueDetail,
InvestigationDialog,
BatchReviewWizard
} from './github-issues/components';
import { GitHubSetupModal } from './GitHubSetupModal';
import type { GitHubIssue } from '../../shared/types';
import type { GitHubIssuesProps } from './github-issues/types';
BatchReviewWizard,
} from "./github-issues/components";
import { GitHubSetupModal } from "./GitHubSetupModal";
import type { GitHubIssue } from "../../shared/types";
import type { GitHubIssuesProps } from "./github-issues/types";
export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesProps) {
const projects = useProjectStore((state) => state.projects);
@@ -28,19 +33,20 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP
isLoading,
error,
selectedIssueNumber,
selectedIssue,
filterState,
selectIssue,
getFilteredIssues,
getOpenIssuesCount,
handleRefresh,
handleFilterChange
handleFilterChange,
} = useGitHubIssues(selectedProject?.id);
const {
investigationStatus,
lastInvestigationResult,
startInvestigation,
resetInvestigationStatus
resetInvestigationStatus,
} = useGitHubInvestigation(selectedProject?.id);
const { searchQuery, setSearchQuery, filteredIssues } = useIssueFiltering(getFilteredIssues());
@@ -66,15 +72,16 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP
closeWizard,
startAnalysis,
approveBatches,
} = useAnalyzePreview({ projectId: selectedProject?.id || '' });
} = useAnalyzePreview({ projectId: selectedProject?.id || "" });
const [showInvestigateDialog, setShowInvestigateDialog] = useState(false);
const [selectedIssueForInvestigation, setSelectedIssueForInvestigation] = useState<GitHubIssue | null>(null);
const [selectedIssueForInvestigation, setSelectedIssueForInvestigation] =
useState<GitHubIssue | null>(null);
const [showGitHubSetup, setShowGitHubSetup] = useState(false);
// Show GitHub setup modal when module is not installed
useEffect(() => {
if (analysisError?.includes('GitHub automation module not installed')) {
if (analysisError?.includes("GitHub automation module not installed")) {
setShowGitHubSetup(true);
}
}, [analysisError]);
@@ -104,34 +111,30 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP
setShowInvestigateDialog(true);
}, []);
const handleStartInvestigation = useCallback((selectedCommentIds: number[]) => {
if (selectedIssueForInvestigation) {
startInvestigation(selectedIssueForInvestigation, selectedCommentIds);
}
}, [selectedIssueForInvestigation, startInvestigation]);
const handleStartInvestigation = useCallback(
(selectedCommentIds: number[]) => {
if (selectedIssueForInvestigation) {
startInvestigation(selectedIssueForInvestigation, selectedCommentIds);
}
},
[selectedIssueForInvestigation, startInvestigation]
);
const handleCloseDialog = useCallback(() => {
setShowInvestigateDialog(false);
resetInvestigationStatus();
}, [resetInvestigationStatus]);
const selectedIssue = issues.find(i => i.number === selectedIssueNumber);
// Not connected state
if (!syncStatus?.connected) {
return (
<NotConnectedState
error={syncStatus?.error || null}
onOpenSettings={onOpenSettings}
/>
);
return <NotConnectedState error={syncStatus?.error || null} onOpenSettings={onOpenSettings} />;
}
return (
<div className="flex-1 flex flex-col h-full">
{/* Header */}
<IssueListHeader
repoFullName={syncStatus.repoFullName ?? ''}
repoFullName={syncStatus.repoFullName ?? ""}
openIssuesCount={getOpenIssuesCount()}
isLoading={isLoading}
searchQuery={searchQuery}
@@ -199,7 +202,7 @@ export function GitHubIssues({ onOpenSettings, onNavigateToTask }: GitHubIssuesP
<BatchReviewWizard
isOpen={isWizardOpen}
onClose={closeWizard}
projectId={selectedProject?.id || ''}
projectId={selectedProject?.id || ""}
onStartAnalysis={startAnalysis}
onApproveBatches={approveBatches}
analysisProgress={analysisProgress}
@@ -1,21 +1,21 @@
import { useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useProjectStore } from '../stores/project-store';
import { useTaskStore } from '../stores/task-store';
import { useGitLabIssues, useGitLabInvestigation, useIssueFiltering } from './gitlab-issues/hooks';
import { useState, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useProjectStore } from "../stores/project-store";
import { useTaskStore } from "../stores/task-store";
import { useGitLabIssues, useGitLabInvestigation, useIssueFiltering } from "./gitlab-issues/hooks";
import {
NotConnectedState,
EmptyState,
IssueListHeader,
IssueList,
IssueDetail,
InvestigationDialog
} from './gitlab-issues/components';
import type { GitLabIssue } from '../../shared/types';
import type { GitLabIssuesProps } from './gitlab-issues/types';
InvestigationDialog,
} from "./gitlab-issues/components";
import type { GitLabIssue } from "../../shared/types";
import type { GitLabIssuesProps } from "./gitlab-issues/types";
export function GitLabIssues({ onOpenSettings, onNavigateToTask }: GitLabIssuesProps) {
const { t } = useTranslation('gitlab');
const { t } = useTranslation("gitlab");
const projects = useProjectStore((state) => state.projects);
const selectedProjectId = useProjectStore((state) => state.selectedProjectId);
const selectedProject = projects.find((p) => p.id === selectedProjectId);
@@ -27,25 +27,27 @@ export function GitLabIssues({ onOpenSettings, onNavigateToTask }: GitLabIssuesP
isLoading,
error,
selectedIssueIid,
selectedIssue,
filterState,
selectIssue,
getFilteredIssues,
getOpenIssuesCount,
handleRefresh,
handleFilterChange
handleFilterChange,
} = useGitLabIssues(selectedProject?.id);
const {
investigationStatus,
lastInvestigationResult,
startInvestigation,
resetInvestigationStatus
resetInvestigationStatus,
} = useGitLabInvestigation(selectedProject?.id);
const { searchQuery, setSearchQuery, filteredIssues } = useIssueFiltering(getFilteredIssues());
const [showInvestigateDialog, setShowInvestigateDialog] = useState(false);
const [selectedIssueForInvestigation, setSelectedIssueForInvestigation] = useState<GitLabIssue | null>(null);
const [selectedIssueForInvestigation, setSelectedIssueForInvestigation] =
useState<GitLabIssue | null>(null);
// Build a map of GitLab issue IIDs to task IDs for quick lookup
const issueToTaskMap = useMemo(() => {
@@ -63,34 +65,30 @@ export function GitLabIssues({ onOpenSettings, onNavigateToTask }: GitLabIssuesP
setShowInvestigateDialog(true);
}, []);
const handleStartInvestigation = useCallback((selectedNoteIds: number[]) => {
if (selectedIssueForInvestigation) {
startInvestigation(selectedIssueForInvestigation, selectedNoteIds);
}
}, [selectedIssueForInvestigation, startInvestigation]);
const handleStartInvestigation = useCallback(
(selectedNoteIds: number[]) => {
if (selectedIssueForInvestigation) {
startInvestigation(selectedIssueForInvestigation, selectedNoteIds);
}
},
[selectedIssueForInvestigation, startInvestigation]
);
const handleCloseDialog = useCallback(() => {
setShowInvestigateDialog(false);
resetInvestigationStatus();
}, [resetInvestigationStatus]);
const selectedIssue = issues.find(i => i.iid === selectedIssueIid);
// Not connected state
if (!syncStatus?.connected) {
return (
<NotConnectedState
error={syncStatus?.error || null}
onOpenSettings={onOpenSettings}
/>
);
return <NotConnectedState error={syncStatus?.error || null} onOpenSettings={onOpenSettings} />;
}
return (
<div className="flex-1 flex flex-col h-full">
{/* Header */}
<IssueListHeader
projectPath={syncStatus.projectPathWithNamespace ?? ''}
projectPath={syncStatus.projectPathWithNamespace ?? ""}
openIssuesCount={getOpenIssuesCount()}
isLoading={isLoading}
searchQuery={searchQuery}
@@ -129,7 +127,7 @@ export function GitLabIssues({ onOpenSettings, onNavigateToTask }: GitLabIssuesP
onViewTask={onNavigateToTask}
/>
) : (
<EmptyState message={t('empty.selectIssue')} />
<EmptyState message={t("empty.selectIssue")} />
)}
</div>
</div>
@@ -1,12 +1,12 @@
import { useEffect, useCallback, useRef } from 'react';
import { useEffect, useCallback, useRef, useMemo } from "react";
import {
useIssuesStore,
useSyncStatusStore,
loadGitHubIssues,
checkGitHubConnection,
type IssueFilterState
} from '../../../stores/github';
import type { FilterState } from '../types';
type IssueFilterState,
} from "../../../stores/github";
import type { FilterState } from "../types";
export function useGitHubIssues(projectId: string | undefined) {
const {
@@ -18,7 +18,7 @@ export function useGitHubIssues(projectId: string | undefined) {
selectIssue,
setFilterState,
getFilteredIssues,
getOpenIssuesCount
getOpenIssuesCount,
} = useIssuesStore();
const { syncStatus } = useSyncStatusStore();
@@ -50,12 +50,20 @@ export function useGitHubIssues(projectId: string | undefined) {
}
}, [projectId, filterState]);
const handleFilterChange = useCallback((state: FilterState) => {
setFilterState(state);
if (projectId) {
loadGitHubIssues(projectId, state);
}
}, [projectId, setFilterState]);
const handleFilterChange = useCallback(
(state: FilterState) => {
setFilterState(state);
if (projectId) {
loadGitHubIssues(projectId, state);
}
},
[projectId, setFilterState]
);
// Compute selectedIssue from issues array
const selectedIssue = useMemo(() => {
return issues.find((i) => i.number === selectedIssueNumber) || null;
}, [issues, selectedIssueNumber]);
return {
issues,
@@ -63,11 +71,12 @@ export function useGitHubIssues(projectId: string | undefined) {
isLoading,
error,
selectedIssueNumber,
selectedIssue,
filterState,
selectIssue,
getFilteredIssues,
getOpenIssuesCount,
handleRefresh,
handleFilterChange
handleFilterChange,
};
}
@@ -1,11 +1,11 @@
import { useCallback } from 'react';
import { GitPullRequest, RefreshCw, ExternalLink, Settings } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useProjectStore } from '../../stores/project-store';
import { useGitHubPRs, usePRFiltering } from './hooks';
import { PRList, PRDetail, PRFilterBar } from './components';
import { Button } from '../ui/button';
import { ResizablePanels } from '../ui/resizable-panels';
import { useCallback } from "react";
import { GitPullRequest, RefreshCw, ExternalLink, Settings } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useProjectStore } from "../../stores/project-store";
import { useGitHubPRs, usePRFiltering } from "./hooks";
import { PRList, PRDetail, PRFilterBar } from "./components";
import { Button } from "../ui/button";
import { ResizablePanels } from "../ui/resizable-panels";
interface GitHubPRsProps {
onOpenSettings?: () => void;
@@ -15,7 +15,7 @@ interface GitHubPRsProps {
function NotConnectedState({
error,
onOpenSettings,
t
t,
}: {
error: string | null;
onOpenSettings?: () => void;
@@ -25,14 +25,12 @@ function NotConnectedState({
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center max-w-md">
<GitPullRequest className="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" />
<h3 className="text-lg font-medium mb-2">{t('prReview.notConnected')}</h3>
<p className="text-sm text-muted-foreground mb-4">
{error || t('prReview.connectPrompt')}
</p>
<h3 className="text-lg font-medium mb-2">{t("prReview.notConnected")}</h3>
<p className="text-sm text-muted-foreground mb-4">{error || t("prReview.connectPrompt")}</p>
{onOpenSettings && (
<Button onClick={onOpenSettings} variant="outline">
<Settings className="h-4 w-4 mr-2" />
{t('prReview.openSettings')}
{t("prReview.openSettings")}
</Button>
)}
</div>
@@ -52,7 +50,7 @@ function EmptyState({ message }: { message: string }) {
}
export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps) {
const { t } = useTranslation('common');
const { t } = useTranslation("common");
const projects = useProjectStore((state) => state.projects);
const selectedProjectId = useProjectStore((state) => state.selectedProjectId);
const selectedProject = projects.find((p) => p.id === selectedProjectId);
@@ -82,14 +80,11 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
isConnected,
repoFullName,
getReviewStateForPR,
selectedPR,
} = useGitHubPRs(selectedProject?.id, { isActive });
const selectedPR = prs.find(pr => pr.number === selectedPRNumber);
// Get previousResult and newCommitsCheck for follow-up review continuity
const selectedPRReviewState = selectedPRNumber
? getReviewStateForPR(selectedPRNumber)
: null;
const selectedPRReviewState = selectedPRNumber ? getReviewStateForPR(selectedPRNumber) : null;
const previousReviewResult = selectedPRReviewState?.previousResult ?? null;
const storedNewCommitsCheck = selectedPRReviewState?.newCommitsCheck ?? null;
@@ -130,30 +125,45 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
}
}, [selectedPRNumber, cancelReview]);
const handlePostReview = useCallback(async (selectedFindingIds?: string[], options?: { forceApprove?: boolean }): Promise<boolean> => {
if (selectedPRNumber && reviewResult) {
return await postReview(selectedPRNumber, selectedFindingIds, options);
}
return false;
}, [selectedPRNumber, reviewResult, postReview]);
const handlePostReview = useCallback(
async (
selectedFindingIds?: string[],
options?: { forceApprove?: boolean }
): Promise<boolean> => {
if (selectedPRNumber && reviewResult) {
return await postReview(selectedPRNumber, selectedFindingIds, options);
}
return false;
},
[selectedPRNumber, reviewResult, postReview]
);
const handlePostComment = useCallback(async (body: string) => {
if (selectedPRNumber) {
await postComment(selectedPRNumber, body);
}
}, [selectedPRNumber, postComment]);
const handlePostComment = useCallback(
async (body: string) => {
if (selectedPRNumber) {
await postComment(selectedPRNumber, body);
}
},
[selectedPRNumber, postComment]
);
const handleMergePR = useCallback(async (mergeMethod?: 'merge' | 'squash' | 'rebase') => {
if (selectedPRNumber) {
await mergePR(selectedPRNumber, mergeMethod);
}
}, [selectedPRNumber, mergePR]);
const handleMergePR = useCallback(
async (mergeMethod?: "merge" | "squash" | "rebase") => {
if (selectedPRNumber) {
await mergePR(selectedPRNumber, mergeMethod);
}
},
[selectedPRNumber, mergePR]
);
const handleAssignPR = useCallback(async (username: string) => {
if (selectedPRNumber) {
await assignPR(selectedPRNumber, username);
}
}, [selectedPRNumber, assignPR]);
const handleAssignPR = useCallback(
async (username: string) => {
if (selectedPRNumber) {
await assignPR(selectedPRNumber, username);
}
},
[selectedPRNumber, assignPR]
);
const handleGetLogs = useCallback(async () => {
if (selectedProjectId && selectedPRNumber) {
@@ -174,7 +184,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
<div className="flex items-center gap-3">
<h2 className="text-sm font-medium flex items-center gap-2">
<GitPullRequest className="h-4 w-4" />
{t('prReview.pullRequests')}
{t("prReview.pullRequests")}
</h2>
{repoFullName && (
<a
@@ -188,16 +198,11 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
</a>
)}
<span className="text-xs text-muted-foreground">
{prs.length} {t('prReview.open')}
{prs.length} {t("prReview.open")}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={refresh}
disabled={isLoading}
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
<Button variant="ghost" size="icon" onClick={refresh} disabled={isLoading}>
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
</Button>
</div>
@@ -235,7 +240,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
selectedPR ? (
<PRDetail
pr={selectedPR}
projectId={selectedProjectId || ''}
projectId={selectedProjectId || ""}
reviewResult={reviewResult}
previousReviewResult={previousReviewResult}
reviewProgress={reviewProgress}
@@ -254,7 +259,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
onGetLogs={handleGetLogs}
/>
) : (
<EmptyState message={t('prReview.selectPRToView')} />
<EmptyState message={t("prReview.selectPRToView")} />
)
}
/>
@@ -1,15 +1,19 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import type {
PRData,
PRReviewResult,
PRReviewProgress,
NewCommitsCheck
} from '../../../../preload/api/modules/github-api';
import { usePRReviewStore, startPRReview as storeStartPRReview, startFollowupReview as storeStartFollowupReview } from '../../../stores/github';
NewCommitsCheck,
} from "../../../../preload/api/modules/github-api";
import {
usePRReviewStore,
startPRReview as storeStartPRReview,
startFollowupReview as storeStartFollowupReview,
} from "../../../stores/github";
// Re-export types for consumers
export type { PRData, PRReviewResult, PRReviewProgress };
export type { PRReviewFinding } from '../../../../preload/api/modules/github-api';
export type { PRReviewFinding } from "../../../../preload/api/modules/github-api";
interface UseGitHubPRsOptions {
/** Whether the component is currently active/visible */
@@ -34,18 +38,32 @@ interface UseGitHubPRsResult {
selectPR: (prNumber: number | null) => void;
refresh: () => Promise<void>;
loadMore: () => Promise<void>;
runReview: (prNumber: number) => Promise<void>;
runFollowupReview: (prNumber: number) => Promise<void>;
runReview: (prNumber: number) => void;
runFollowupReview: (prNumber: number) => void;
checkNewCommits: (prNumber: number) => Promise<NewCommitsCheck>;
cancelReview: (prNumber: number) => Promise<boolean>;
postReview: (prNumber: number, selectedFindingIds?: string[], options?: { forceApprove?: boolean }) => Promise<boolean>;
postReview: (
prNumber: number,
selectedFindingIds?: string[],
options?: { forceApprove?: boolean }
) => Promise<boolean>;
postComment: (prNumber: number, body: string) => Promise<boolean>;
mergePR: (prNumber: number, mergeMethod?: 'merge' | 'squash' | 'rebase') => Promise<boolean>;
mergePR: (prNumber: number, mergeMethod?: "merge" | "squash" | "rebase") => Promise<boolean>;
assignPR: (prNumber: number, username: string) => Promise<boolean>;
getReviewStateForPR: (prNumber: number) => { isReviewing: boolean; progress: PRReviewProgress | null; result: PRReviewResult | null; previousResult: PRReviewResult | null; error: string | null; newCommitsCheck?: NewCommitsCheck | null } | null;
getReviewStateForPR: (prNumber: number) => {
isReviewing: boolean;
progress: PRReviewProgress | null;
result: PRReviewResult | null;
previousResult: PRReviewResult | null;
error: string | null;
newCommitsCheck?: NewCommitsCheck | null;
} | null;
}
export function useGitHubPRs(projectId?: string, options: UseGitHubPRsOptions = {}): UseGitHubPRsResult {
export function useGitHubPRs(
projectId?: string,
options: UseGitHubPRsOptions = {}
): UseGitHubPRsResult {
const { isActive = true } = options;
const [prs, setPrs] = useState<PRData[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -63,6 +81,8 @@ export function useGitHubPRs(projectId?: string, options: UseGitHubPRsOptions =
const wasActiveRef = useRef(isActive);
// Track if initial load has happened
const hasLoadedRef = useRef(false);
// Track the current PR being fetched (for race condition prevention)
const currentFetchPRNumberRef = useRef<number | null>(null);
// Get PR review state from the global store
const prReviews = usePRReviewStore((state) => state.prReviews);
@@ -84,99 +104,115 @@ export function useGitHubPRs(projectId?: string, options: UseGitHubPRsOptions =
// Get list of PR numbers currently being reviewed
const activePRReviews = useMemo(() => {
if (!projectId) return [];
return getActivePRReviews(projectId).map(review => review.prNumber);
return getActivePRReviews(projectId).map((review) => review.prNumber);
}, [projectId, prReviews, getActivePRReviews]);
// Helper to get review state for any PR
const getReviewStateForPR = useCallback((prNumber: number) => {
if (!projectId) return null;
const state = getPRReviewState(projectId, prNumber);
if (!state) return null;
return {
isReviewing: state.isReviewing,
progress: state.progress,
result: state.result,
previousResult: state.previousResult,
error: state.error,
newCommitsCheck: state.newCommitsCheck
};
}, [projectId, prReviews, getPRReviewState]);
const getReviewStateForPR = useCallback(
(prNumber: number) => {
if (!projectId) return null;
const state = getPRReviewState(projectId, prNumber);
if (!state) return null;
return {
isReviewing: state.isReviewing,
progress: state.progress,
result: state.result,
previousResult: state.previousResult,
error: state.error,
newCommitsCheck: state.newCommitsCheck,
};
},
[projectId, prReviews, getPRReviewState]
);
// Use detailed PR data if available (includes files), otherwise fall back to list data
const selectedPR = selectedPRDetails || prs.find(pr => pr.number === selectedPRNumber) || null;
// Validate that selectedPRDetails matches selectedPRNumber to avoid showing stale data
const selectedPR = useMemo(() => {
const matchingDetails =
selectedPRDetails?.number === selectedPRNumber ? selectedPRDetails : null;
return matchingDetails || prs.find((pr) => pr.number === selectedPRNumber) || null;
}, [selectedPRDetails, prs, selectedPRNumber]);
// Check connection and fetch PRs
const fetchPRs = useCallback(async (page: number = 1, append: boolean = false) => {
if (!projectId) return;
const fetchPRs = useCallback(
async (page: number = 1, append: boolean = false) => {
if (!projectId) return;
if (append) {
setIsLoadingMore(true);
} else {
setIsLoading(true);
}
setError(null);
if (append) {
setIsLoadingMore(true);
} else {
setIsLoading(true);
}
setError(null);
try {
// First check connection
const connectionResult = await window.electronAPI.github.checkGitHubConnection(projectId);
if (connectionResult.success && connectionResult.data) {
setIsConnected(connectionResult.data.connected);
setRepoFullName(connectionResult.data.repoFullName || null);
try {
// First check connection
const connectionResult = await window.electronAPI.github.checkGitHubConnection(projectId);
if (connectionResult.success && connectionResult.data) {
setIsConnected(connectionResult.data.connected);
setRepoFullName(connectionResult.data.repoFullName || null);
if (connectionResult.data.connected) {
// Fetch PRs with pagination
const result = await window.electronAPI.github.listPRs(projectId, page);
if (result) {
// Check if there are more PRs to load (GitHub returns up to 100 per page)
setHasMore(result.length === 100);
setCurrentPage(page);
if (connectionResult.data.connected) {
// Fetch PRs with pagination
const result = await window.electronAPI.github.listPRs(projectId, page);
if (result) {
// Check if there are more PRs to load (GitHub returns up to 100 per page)
setHasMore(result.length === 100);
setCurrentPage(page);
if (append) {
// Append to existing PRs, deduplicating by PR number
setPrs(prevPrs => {
const existingNumbers = new Set(prevPrs.map(pr => pr.number));
const newPrs = result.filter(pr => !existingNumbers.has(pr.number));
return [...prevPrs, ...newPrs];
if (append) {
// Append to existing PRs, deduplicating by PR number
setPrs((prevPrs) => {
const existingNumbers = new Set(prevPrs.map((pr) => pr.number));
const newPrs = result.filter((pr) => !existingNumbers.has(pr.number));
return [...prevPrs, ...newPrs];
});
} else {
setPrs(result);
}
// Batch preload review results for PRs not in store (single IPC call)
const prsNeedingPreload = result.filter((pr) => {
const existingState = getPRReviewState(projectId, pr.number);
return !existingState?.result;
});
} else {
setPrs(result);
}
// Batch preload review results for PRs not in store (single IPC call)
const prsNeedingPreload = result.filter(pr => {
const existingState = getPRReviewState(projectId, pr.number);
return !existingState?.result;
});
if (prsNeedingPreload.length > 0) {
const prNumbers = prsNeedingPreload.map((pr) => pr.number);
const batchReviews = await window.electronAPI.github.getPRReviewsBatch(
projectId,
prNumbers
);
if (prsNeedingPreload.length > 0) {
const prNumbers = prsNeedingPreload.map(pr => pr.number);
const batchReviews = await window.electronAPI.github.getPRReviewsBatch(projectId, prNumbers);
// Update store with loaded results
for (const reviewResult of Object.values(batchReviews)) {
if (reviewResult) {
usePRReviewStore.getState().setPRReviewResult(projectId, reviewResult, { preserveNewCommitsCheck: true });
// Update store with loaded results
for (const reviewResult of Object.values(batchReviews)) {
if (reviewResult) {
usePRReviewStore.getState().setPRReviewResult(projectId, reviewResult, {
preserveNewCommitsCheck: true,
});
}
}
}
}
// Note: New commits check is now lazy - only done when user selects a PR
// or explicitly triggers a check. This significantly speeds up list loading.
// Note: New commits check is now lazy - only done when user selects a PR
// or explicitly triggers a check. This significantly speeds up list loading.
}
}
} else {
setIsConnected(false);
setRepoFullName(null);
setError(connectionResult.error || "Failed to check connection");
}
} else {
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch PRs");
setIsConnected(false);
setRepoFullName(null);
setError(connectionResult.error || 'Failed to check connection');
} finally {
setIsLoading(false);
setIsLoadingMore(false);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch PRs');
setIsConnected(false);
} finally {
setIsLoading(false);
setIsLoadingMore(false);
}
}, [projectId, getPRReviewState, setNewCommitsCheckAction]);
},
[projectId, getPRReviewState]
);
// Initial load
useEffect(() => {
@@ -210,68 +246,88 @@ export function useGitHubPRs(projectId?: string, options: UseGitHubPRsOptions =
// No need for local IPC listeners - they're handled globally in github-store
const selectPR = useCallback((prNumber: number | null) => {
setSelectedPRNumber(prNumber);
// Note: Don't reset review result - it comes from the store now
// and persists across navigation
const selectPR = useCallback(
(prNumber: number | null) => {
setSelectedPRNumber(prNumber);
// Note: Don't reset review result - it comes from the store now
// and persists across navigation
// Clear previous detailed PR data when deselecting
if (prNumber === null) {
setSelectedPRDetails(null);
return;
}
// Clear previous detailed PR data when deselecting
if (prNumber === null) {
setSelectedPRDetails(null);
currentFetchPRNumberRef.current = null;
return;
}
if (prNumber && projectId) {
// Fetch full PR details including files
setIsLoadingPRDetails(true);
window.electronAPI.github.getPR(projectId, prNumber)
.then(prDetails => {
if (prDetails) {
setSelectedPRDetails(prDetails);
}
})
.catch(err => {
console.warn(`Failed to fetch PR details for #${prNumber}:`, err);
})
.finally(() => {
setIsLoadingPRDetails(false);
});
if (prNumber && projectId) {
// Track the current PR being fetched (for race condition prevention)
currentFetchPRNumberRef.current = prNumber;
// Load existing review from disk if not already in store
const existingState = getPRReviewState(projectId, prNumber);
// Only fetch from disk if we don't have a result in the store
if (!existingState?.result) {
window.electronAPI.github.getPRReview(projectId, prNumber).then(result => {
if (result) {
// Update store with the loaded result
// Preserve newCommitsCheck when loading existing review from disk
usePRReviewStore.getState().setPRReviewResult(projectId, result, { preserveNewCommitsCheck: true });
// Fetch full PR details including files
setIsLoadingPRDetails(true);
window.electronAPI.github
.getPR(projectId, prNumber)
.then((prDetails) => {
// Only update if this response is still for the current PR (prevents race condition)
if (prDetails && prNumber === currentFetchPRNumberRef.current) {
setSelectedPRDetails(prDetails);
}
})
.catch((err) => {
console.warn(`Failed to fetch PR details for #${prNumber}:`, err);
})
.finally(() => {
// Only clear loading state if this was the last fetch
if (prNumber === currentFetchPRNumberRef.current) {
setIsLoadingPRDetails(false);
}
});
// Always check for new commits when selecting a reviewed PR
// This ensures fresh data even if we have a cached check from earlier in the session
const reviewedCommitSha = result.reviewedCommitSha || (result as any).reviewed_commit_sha;
if (reviewedCommitSha) {
window.electronAPI.github.checkNewCommits(projectId, prNumber).then(newCommitsResult => {
// Load existing review from disk if not already in store
const existingState = getPRReviewState(projectId, prNumber);
// Only fetch from disk if we don't have a result in the store
if (!existingState?.result) {
window.electronAPI.github.getPRReview(projectId, prNumber).then((result) => {
if (result) {
// Update store with the loaded result
// Preserve newCommitsCheck when loading existing review from disk
usePRReviewStore
.getState()
.setPRReviewResult(projectId, result, { preserveNewCommitsCheck: true });
// Always check for new commits when selecting a reviewed PR
// This ensures fresh data even if we have a cached check from earlier in the session
const reviewedCommitSha = result.reviewedCommitSha;
if (reviewedCommitSha) {
window.electronAPI.github
.checkNewCommits(projectId, prNumber)
.then((newCommitsResult) => {
setNewCommitsCheckAction(projectId, prNumber, newCommitsResult);
})
.catch((err) => {
console.warn(`Failed to check new commits for PR #${prNumber}:`, err);
});
}
}
});
} else if (existingState?.result) {
// Review already in store - always check for new commits to get fresh status
const reviewedCommitSha = existingState.result.reviewedCommitSha;
if (reviewedCommitSha) {
window.electronAPI.github
.checkNewCommits(projectId, prNumber)
.then((newCommitsResult) => {
setNewCommitsCheckAction(projectId, prNumber, newCommitsResult);
}).catch(err => {
})
.catch((err) => {
console.warn(`Failed to check new commits for PR #${prNumber}:`, err);
});
}
}
});
} else if (existingState?.result) {
// Review already in store - always check for new commits to get fresh status
const reviewedCommitSha = existingState.result.reviewedCommitSha || (existingState.result as any).reviewed_commit_sha;
if (reviewedCommitSha) {
window.electronAPI.github.checkNewCommits(projectId, prNumber).then(newCommitsResult => {
setNewCommitsCheckAction(projectId, prNumber, newCommitsResult);
}).catch(err => {
console.warn(`Failed to check new commits for PR #${prNumber}:`, err);
});
}
}
}
}, [projectId, getPRReviewState, setNewCommitsCheckAction]);
},
[projectId, getPRReviewState, setNewCommitsCheckAction]
);
const refresh = useCallback(async () => {
setCurrentPage(1);
@@ -284,115 +340,155 @@ export function useGitHubPRs(projectId?: string, options: UseGitHubPRsOptions =
await fetchPRs(currentPage + 1, true);
}, [fetchPRs, hasMore, isLoadingMore, isLoading, currentPage]);
const runReview = useCallback(async (prNumber: number) => {
if (!projectId) return;
const runReview = useCallback(
(prNumber: number) => {
if (!projectId) return;
// Use the store function which handles both state and IPC
storeStartPRReview(projectId, prNumber);
}, [projectId]);
// Use the store function which handles both state and IPC
storeStartPRReview(projectId, prNumber);
},
[projectId]
);
const runFollowupReview = useCallback(async (prNumber: number) => {
if (!projectId) return;
const runFollowupReview = useCallback(
(prNumber: number) => {
if (!projectId) return;
// Use the store function which handles both state and IPC
storeStartFollowupReview(projectId, prNumber);
}, [projectId]);
// Use the store function which handles both state and IPC
storeStartFollowupReview(projectId, prNumber);
},
[projectId]
);
const checkNewCommits = useCallback(async (prNumber: number): Promise<NewCommitsCheck> => {
if (!projectId) {
return { hasNewCommits: false, newCommitCount: 0 };
}
try {
const result = await window.electronAPI.github.checkNewCommits(projectId, prNumber);
// Cache the result in the store so the list view can use it
// Use the action from the hook subscription to ensure proper React re-renders
setNewCommitsCheckAction(projectId, prNumber, result);
return result;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to check for new commits');
return { hasNewCommits: false, newCommitCount: 0 };
}
}, [projectId, setNewCommitsCheckAction]);
const cancelReview = useCallback(async (prNumber: number): Promise<boolean> => {
if (!projectId) return false;
try {
const success = await window.electronAPI.github.cancelPRReview(projectId, prNumber);
if (success) {
// Update store to mark review as cancelled
usePRReviewStore.getState().setPRReviewError(projectId, prNumber, 'Review cancelled by user');
const checkNewCommits = useCallback(
async (prNumber: number): Promise<NewCommitsCheck> => {
if (!projectId) {
return { hasNewCommits: false, newCommitCount: 0 };
}
return success;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to cancel review');
return false;
}
}, [projectId]);
const postReview = useCallback(async (prNumber: number, selectedFindingIds?: string[], options?: { forceApprove?: boolean }): Promise<boolean> => {
if (!projectId) return false;
try {
const result = await window.electronAPI.github.checkNewCommits(projectId, prNumber);
// Cache the result in the store so the list view can use it
// Use the action from the hook subscription to ensure proper React re-renders
setNewCommitsCheckAction(projectId, prNumber, result);
return result;
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to check for new commits");
return { hasNewCommits: false, newCommitCount: 0 };
}
},
[projectId, setNewCommitsCheckAction]
);
try {
const success = await window.electronAPI.github.postPRReview(projectId, prNumber, selectedFindingIds, options);
if (success) {
// Reload review result to get updated postedAt and finding status
const result = await window.electronAPI.github.getPRReview(projectId, prNumber);
if (result) {
// Preserve newCommitsCheck - posting doesn't change whether there are new commits
usePRReviewStore.getState().setPRReviewResult(projectId, result, { preserveNewCommitsCheck: true });
const cancelReview = useCallback(
async (prNumber: number): Promise<boolean> => {
if (!projectId) return false;
try {
const success = await window.electronAPI.github.cancelPRReview(projectId, prNumber);
if (success) {
// Update store to mark review as cancelled
usePRReviewStore
.getState()
.setPRReviewError(projectId, prNumber, "Review cancelled by user");
}
return success;
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to cancel review");
return false;
}
return success;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to post review');
return false;
}
}, [projectId]);
},
[projectId]
);
const postComment = useCallback(async (prNumber: number, body: string): Promise<boolean> => {
if (!projectId) return false;
const postReview = useCallback(
async (
prNumber: number,
selectedFindingIds?: string[],
options?: { forceApprove?: boolean }
): Promise<boolean> => {
if (!projectId) return false;
try {
return await window.electronAPI.github.postPRComment(projectId, prNumber, body);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to post comment');
return false;
}
}, [projectId]);
const mergePR = useCallback(async (prNumber: number, mergeMethod: 'merge' | 'squash' | 'rebase' = 'squash'): Promise<boolean> => {
if (!projectId) return false;
try {
const success = await window.electronAPI.github.mergePR(projectId, prNumber, mergeMethod);
if (success) {
// Refresh PR list after merge
await fetchPRs();
try {
const success = await window.electronAPI.github.postPRReview(
projectId,
prNumber,
selectedFindingIds,
options
);
if (success) {
// Reload review result to get updated postedAt and finding status
const result = await window.electronAPI.github.getPRReview(projectId, prNumber);
if (result) {
// Preserve newCommitsCheck - posting doesn't change whether there are new commits
usePRReviewStore
.getState()
.setPRReviewResult(projectId, result, { preserveNewCommitsCheck: true });
}
}
return success;
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to post review");
return false;
}
return success;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to merge PR');
return false;
}
}, [projectId, fetchPRs]);
},
[projectId]
);
const assignPR = useCallback(async (prNumber: number, username: string): Promise<boolean> => {
if (!projectId) return false;
const postComment = useCallback(
async (prNumber: number, body: string): Promise<boolean> => {
if (!projectId) return false;
try {
const success = await window.electronAPI.github.assignPR(projectId, prNumber, username);
if (success) {
// Refresh PR list to update assignees
await fetchPRs();
try {
return await window.electronAPI.github.postPRComment(projectId, prNumber, body);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to post comment");
return false;
}
return success;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to assign user');
return false;
}
}, [projectId, fetchPRs]);
},
[projectId]
);
const mergePR = useCallback(
async (
prNumber: number,
mergeMethod: "merge" | "squash" | "rebase" = "squash"
): Promise<boolean> => {
if (!projectId) return false;
try {
const success = await window.electronAPI.github.mergePR(projectId, prNumber, mergeMethod);
if (success) {
// Refresh PR list after merge
await fetchPRs();
}
return success;
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to merge PR");
return false;
}
},
[projectId, fetchPRs]
);
const assignPR = useCallback(
async (prNumber: number, username: string): Promise<boolean> => {
if (!projectId) return false;
try {
const success = await window.electronAPI.github.assignPR(projectId, prNumber, username);
if (success) {
// Refresh PR list to update assignees
await fetchPRs();
}
return success;
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to assign user");
return false;
}
},
[projectId, fetchPRs]
);
return {
prs,
@@ -1,6 +1,10 @@
import { useEffect, useCallback } from 'react';
import { useGitLabStore, loadGitLabIssues, checkGitLabConnection } from '../../../stores/gitlab-store';
import type { FilterState } from '../types';
import { useEffect, useCallback, useMemo } from "react";
import {
useGitLabStore,
loadGitLabIssues,
checkGitLabConnection,
} from "../../../stores/gitlab-store";
import type { FilterState } from "../types";
export function useGitLabIssues(projectId: string | undefined) {
const {
@@ -13,7 +17,7 @@ export function useGitLabIssues(projectId: string | undefined) {
selectIssue,
setFilterState,
getFilteredIssues,
getOpenIssuesCount
getOpenIssuesCount,
} = useGitLabStore();
// Always check connection when component mounts or projectId changes
@@ -39,12 +43,20 @@ export function useGitLabIssues(projectId: string | undefined) {
}
}, [projectId, filterState]);
const handleFilterChange = useCallback((state: FilterState) => {
setFilterState(state);
if (projectId) {
loadGitLabIssues(projectId, state);
}
}, [projectId, setFilterState]);
const handleFilterChange = useCallback(
(state: FilterState) => {
setFilterState(state);
if (projectId) {
loadGitLabIssues(projectId, state);
}
},
[projectId, setFilterState]
);
// Compute selectedIssue from issues array
const selectedIssue = useMemo(() => {
return issues.find((i) => i.iid === selectedIssueIid) || null;
}, [issues, selectedIssueIid]);
return {
issues,
@@ -52,11 +64,12 @@ export function useGitLabIssues(projectId: string | undefined) {
isLoading,
error,
selectedIssueIid,
selectedIssue,
filterState,
selectIssue,
getFilteredIssues,
getOpenIssuesCount,
handleRefresh,
handleFilterChange
handleFilterChange,
};
}