Fix PR List Update on Post Status Click (#1207)

* auto-claude: subtask-1-1 - Add markReviewPosted function to useGitHubPRs hook

Fix PR list not updating when "Post Status" button is clicked for blocked PRs.

Changes:
- Add markReviewPosted function to useGitHubPRs hook that updates the store
  with hasPostedFindings: true
- Pass markReviewPosted through GitHubPRs.tsx to PRDetail component
- Call onMarkReviewPosted in handlePostBlockedStatus after successful post

This ensures the PR list status display updates immediately when posting
blocked status (BLOCKED/NEEDS_REVISION verdicts with no findings).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: persist hasPostedFindings flag to disk in markReviewPosted

The markReviewPosted function was only updating the in-memory Zustand
store without persisting the has_posted_findings flag to the review
JSON file on disk. After an app restart, the flag would be lost.

This commit adds:
- New IPC handler GITHUB_PR_MARK_REVIEW_POSTED that updates the review
  JSON file on disk with has_posted_findings=true and posted_at timestamp
- New API method markReviewPosted in github-api.ts
- Updated markReviewPosted in useGitHubPRs.ts to call the IPC handler
  first, then update the in-memory store

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address follow-up review findings for markReviewPosted

Fixes several issues identified in the follow-up PR review:

1. Race condition with prNumber: onMarkReviewPosted callback now accepts
   prNumber as a parameter instead of relying on closure state, preventing
   wrong PR updates when user switches PRs during async operations.

2. Silent failure handling: handlePostBlockedStatus now checks the return
   value of onPostComment and only marks review as posted on success.

3. Missing postedAt timestamp: markReviewPosted now includes postedAt
   timestamp in the store update for consistency with disk state.

4. Store not updated when result not loaded: If the review result hasn't
   been loaded yet (race condition), markReviewPosted now reloads it from
   disk after persistence to ensure the UI reflects the correct state.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: update PostCommentFn type in PRDetail tests to match new signature

Update the mock type to return Promise<boolean> instead of void | Promise<void>
to match the updated onPostComment interface that now returns success status.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: correct mock return value type in PRDetail integration test

The mockOnPostComment.mockResolvedValue() was passing undefined instead of
boolean, causing TypeScript type check failures in CI. PostCommentFn returns
Promise<boolean>, so the mock must also return a boolean value.

* fix(security): eliminate TOCTOU race condition in markReviewPosted handler

Remove separate fs.existsSync() check before fs.readFileSync() to prevent
time-of-check to time-of-use (TOCTOU) race condition flagged by CodeQL.

Instead, let readFileSync throw ENOENT if file doesn't exist and handle it
in the catch block with specific error code checking.

* chore: merge develop and fix additional test type error

Resolve merge conflict in ipc.ts by keeping both new IPC channels:
- GITHUB_PR_MARK_REVIEW_POSTED (from this branch)
- GITHUB_PR_UPDATE_BRANCH (from develop)

Fix second occurrence of mockOnPostComment.mockResolvedValue(undefined)
type error in PRDetail integration tests.

---------

Co-authored-by: Test User <test@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-17 20:51:48 +01:00
committed by GitHub
parent 3024d547a8
commit 3085e392d2
8 changed files with 108 additions and 10 deletions
@@ -1777,6 +1777,47 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
}
);
// Mark review as posted (persists has_posted_findings to disk)
ipcMain.handle(
IPC_CHANNELS.GITHUB_PR_MARK_REVIEW_POSTED,
async (_, projectId: string, prNumber: number): Promise<boolean> => {
debugLog("markReviewPosted handler called", { projectId, prNumber });
const result = await withProjectOrNull(projectId, async (project) => {
try {
const reviewPath = path.join(getGitHubDir(project), "pr", `review_${prNumber}.json`);
// Read file directly without separate existence check to avoid TOCTOU race condition
// If file doesn't exist, readFileSync will throw ENOENT which we handle below
const rawData = fs.readFileSync(reviewPath, "utf-8");
// Sanitize data before parsing (review may contain data from GitHub API)
const sanitizedData = sanitizeNetworkData(rawData);
const data = JSON.parse(sanitizedData);
// Mark as posted
data.has_posted_findings = true;
data.posted_at = new Date().toISOString();
fs.writeFileSync(reviewPath, JSON.stringify(data, null, 2), "utf-8");
debugLog("Marked review as posted", { prNumber });
return true;
} catch (error) {
// Handle file not found (ENOENT) separately for clearer logging
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
debugLog("Review file not found", { prNumber });
return false;
}
debugLog("Failed to mark review as posted", {
prNumber,
error: error instanceof Error ? error.message : error,
});
return false;
}
});
return result ?? false;
}
);
// Post comment to PR
ipcMain.handle(
IPC_CHANNELS.GITHUB_PR_POST_COMMENT,
@@ -272,6 +272,7 @@ export interface GitHubAPI {
postPRComment: (projectId: string, prNumber: number, body: string) => Promise<boolean>;
mergePR: (projectId: string, prNumber: number, mergeMethod?: 'merge' | 'squash' | 'rebase') => Promise<boolean>;
assignPR: (projectId: string, prNumber: number, username: string) => Promise<boolean>;
markReviewPosted: (projectId: string, prNumber: number) => Promise<boolean>;
getPRReview: (projectId: string, prNumber: number) => Promise<PRReviewResult | null>;
getPRReviewsBatch: (projectId: string, prNumbers: number[]) => Promise<Record<number, PRReviewResult | null>>;
@@ -678,6 +679,9 @@ export const createGitHubAPI = (): GitHubAPI => ({
assignPR: (projectId: string, prNumber: number, username: string): Promise<boolean> =>
invokeIpc(IPC_CHANNELS.GITHUB_PR_ASSIGN, projectId, prNumber, username),
markReviewPosted: (projectId: string, prNumber: number): Promise<boolean> =>
invokeIpc(IPC_CHANNELS.GITHUB_PR_MARK_REVIEW_POSTED, projectId, prNumber),
getPRReview: (projectId: string, prNumber: number): Promise<PRReviewResult | null> =>
invokeIpc(IPC_CHANNELS.GITHUB_PR_GET_REVIEW, projectId, prNumber),
@@ -77,6 +77,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
postComment,
mergePR,
assignPR,
markReviewPosted,
refresh,
loadMore,
isConnected,
@@ -140,10 +141,11 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
);
const handlePostComment = useCallback(
async (body: string) => {
async (body: string): Promise<boolean> => {
if (selectedPRNumber) {
await postComment(selectedPRNumber, body);
return await postComment(selectedPRNumber, body);
}
return false;
},
[selectedPRNumber, postComment]
);
@@ -173,6 +175,10 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
return null;
}, [selectedProjectId, selectedPRNumber]);
const handleMarkReviewPosted = useCallback(async (prNumber: number) => {
await markReviewPosted(prNumber);
}, [markReviewPosted]);
// Not connected state
if (!isConnected) {
return <NotConnectedState error={error} onOpenSettings={onOpenSettings} t={t} />;
@@ -259,6 +265,7 @@ export function GitHubPRs({ onOpenSettings, isActive = false }: GitHubPRsProps)
onMergePR={handleMergePR}
onAssignPR={handleAssignPR}
onGetLogs={handleGetLogs}
onMarkReviewPosted={handleMarkReviewPosted}
/>
) : (
<EmptyState message={t("prReview.selectPRToView")} />
@@ -52,10 +52,11 @@ interface PRDetailProps {
onCheckNewCommits: () => Promise<NewCommitsCheck>;
onCancelReview: () => void;
onPostReview: (selectedFindingIds?: string[], options?: { forceApprove?: boolean }) => Promise<boolean>;
onPostComment: (body: string) => void;
onPostComment: (body: string) => Promise<boolean>;
onMergePR: (mergeMethod?: 'merge' | 'squash' | 'rebase') => void;
onAssignPR: (username: string) => void;
onGetLogs: () => Promise<PRLogsType | null>;
onMarkReviewPosted?: (prNumber: number) => Promise<void>;
}
function getStatusColor(status: PRReviewResult['overallStatus']): string {
@@ -89,6 +90,7 @@ export function PRDetail({
onMergePR,
onAssignPR: _onAssignPR,
onGetLogs,
onMarkReviewPosted,
}: PRDetailProps) {
const { t } = useTranslation('common');
// Selection state for findings
@@ -780,12 +782,17 @@ ${reviewResult.summary}
${t('prReview.blockedStatusMessageFooter')}`;
await Promise.resolve(onPostComment(blockedStatusMessage));
const success = await onPostComment(blockedStatusMessage);
// Only mark as posted on success if PR hasn't changed
if (pr.number === currentPr) {
// Only mark as posted on success if PR hasn't changed AND comment was posted successfully
if (success && pr.number === currentPr) {
setBlockedStatusPosted(true);
setBlockedStatusError(null);
// Update the store to mark review as posted so PR list reflects the change
// Pass prNumber explicitly to avoid race conditions with PR selection changes
await onMarkReviewPosted?.(currentPr);
} else if (!success && pr.number === currentPr) {
setBlockedStatusError('Failed to post comment');
}
} catch (err) {
console.error('Failed to post blocked status comment:', err);
@@ -14,8 +14,8 @@ import type { PRData, PRReviewResult } from '../../hooks/useGitHubPRs';
import type { NewCommitsCheck } from '../../../../../preload/api/modules/github-api';
// Mock window.electronAPI
type PostCommentFn = (body: string) => void | Promise<void>;
const mockOnPostComment = vi.fn<PostCommentFn>();
type PostCommentFn = (body: string) => Promise<boolean>;
const mockOnPostComment = vi.fn<PostCommentFn>().mockResolvedValue(true);
const mockOnPostReview = vi.fn();
const mockOnRunReview = vi.fn();
const mockOnRunFollowupReview = vi.fn();
@@ -128,7 +128,7 @@ describe('PRDetail - Clean Review State Reset Integration', () => {
newCommitCount: 0
});
// Resolve successfully by default
mockOnPostComment.mockResolvedValue(undefined);
mockOnPostComment.mockResolvedValue(true);
});
it('should reset cleanReviewPosted state when pr.number changes', async () => {
@@ -432,7 +432,7 @@ describe('PRDetail - Follow-up Review Trigger Integration', () => {
hasCommitsAfterPosting: false,
newCommitCount: 0
});
mockOnPostComment.mockResolvedValue(undefined);
mockOnPostComment.mockResolvedValue(true);
});
it('should display "Ready for Follow-up" status when new commits exist after posting', async () => {
@@ -52,6 +52,7 @@ interface UseGitHubPRsResult {
postComment: (prNumber: number, body: string) => Promise<boolean>;
mergePR: (prNumber: number, mergeMethod?: "merge" | "squash" | "rebase") => Promise<boolean>;
assignPR: (prNumber: number, username: string) => Promise<boolean>;
markReviewPosted: (prNumber: number) => Promise<void>;
getReviewStateForPR: (prNumber: number) => {
isReviewing: boolean;
startedAt: string | null;
@@ -557,6 +558,41 @@ export function useGitHubPRs(
[projectId, fetchPRs]
);
const markReviewPosted = useCallback(
async (prNumber: number): Promise<void> => {
if (!projectId) return;
// Persist to disk first
const success = await window.electronAPI.github.markReviewPosted(projectId, prNumber);
if (!success) return;
// Get the current timestamp for consistent update
const postedAt = new Date().toISOString();
// Update the in-memory store
const existingState = getPRReviewState(projectId, prNumber);
if (existingState?.result) {
// If we have the result loaded, update it with hasPostedFindings and postedAt
usePRReviewStore.getState().setPRReviewResult(
projectId,
{ ...existingState.result, hasPostedFindings: true, postedAt },
{ preserveNewCommitsCheck: true }
);
} else {
// If result not loaded yet (race condition), reload from disk to get updated state
const result = await window.electronAPI.github.getPRReview(projectId, prNumber);
if (result) {
usePRReviewStore.getState().setPRReviewResult(
projectId,
result,
{ preserveNewCommitsCheck: true }
);
}
}
},
[projectId, getPRReviewState]
);
return {
prs,
isLoading,
@@ -585,6 +621,7 @@ export function useGitHubPRs(
postComment,
mergePR,
assignPR,
markReviewPosted,
getReviewStateForPR,
};
}
@@ -204,6 +204,7 @@ const browserMockAPI: ElectronAPI = {
postPRComment: async () => true,
mergePR: async () => true,
assignPR: async () => true,
markReviewPosted: async () => true,
getPRReview: async () => null,
getPRReviewsBatch: async () => ({}),
deletePRReview: async () => true,
@@ -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_MARK_REVIEW_POSTED: 'github:pr:markReviewPosted',
GITHUB_PR_UPDATE_BRANCH: 'github:pr:updateBranch',
// GitHub PR Review events (main -> renderer)