Skip to content

Commit ef2ceb9

Browse files
committed
Fix PR merging never updating state
1 parent d1b5f88 commit ef2ceb9

File tree

3 files changed

+165
-29
lines changed

3 files changed

+165
-29
lines changed

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,7 @@ Bad:
3333
## Linting
3434

3535
Always run `bun typecheck` and `bun fmt` after changes to ensure that files are formatted and have no type errors.
36+
37+
## Debugging
38+
39+
If the user provides a PR identifier, you should use the `gh` CLI to inspect the API so we can fix our implementation if it appears incorrect.

src/browser/components/pr-overview.tsx

Lines changed: 112 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ export const PROverview = memo(function PROverview() {
111111
const [workflowRunsAwaitingApproval, setWorkflowRunsAwaitingApproval] =
112112
useState<Array<{ id: number; name: string; html_url: string }>>([]);
113113
const [approvingWorkflows, setApprovingWorkflows] = useState(false);
114+
// Track recently approved workflow IDs to filter out stale API responses
115+
const recentlyApprovedWorkflowIds = useRef<Set<number>>(new Set());
114116
const [conversation, setConversation] = useState<IssueComment[]>([]);
115117
const [commits, setCommits] = useState<PRCommit[]>([]);
116118
const [timeline, setTimeline] = useState<TimelineEvent[]>([]);
@@ -204,16 +206,35 @@ export const PROverview = memo(function PROverview() {
204206
setChecksLastUpdated(new Date());
205207

206208
// Find workflow runs awaiting approval (fork PRs)
209+
// Filter out recently approved workflows to prevent stale API data from reverting optimistic updates
207210
const awaitingApproval = workflowRunsData.workflow_runs
208211
.filter(
209-
(run: { conclusion: string | null }) =>
210-
run.conclusion === "action_required"
212+
(run: { id: number; conclusion: string | null }) =>
213+
run.conclusion === "action_required" &&
214+
!recentlyApprovedWorkflowIds.current.has(run.id)
211215
)
212216
.map((run: { id: number; name?: string | null; html_url: string }) => ({
213217
id: run.id,
214218
name: run.name || "Workflow",
215219
html_url: run.html_url,
216220
}));
221+
222+
// Clear recently approved IDs that are no longer showing as action_required
223+
// (meaning GitHub's API has caught up)
224+
const currentActionRequiredIds = new Set(
225+
workflowRunsData.workflow_runs
226+
.filter(
227+
(run: { conclusion: string | null }) =>
228+
run.conclusion === "action_required"
229+
)
230+
.map((run: { id: number }) => run.id)
231+
);
232+
for (const id of recentlyApprovedWorkflowIds.current) {
233+
if (!currentActionRequiredIds.has(id)) {
234+
recentlyApprovedWorkflowIds.current.delete(id);
235+
}
236+
}
237+
217238
setWorkflowRunsAwaitingApproval(awaitingApproval);
218239
} catch {
219240
// Ignore errors on refresh
@@ -345,29 +366,51 @@ export const PROverview = memo(function PROverview() {
345366
merge_method: mergeMethod,
346367
});
347368

348-
window.location.reload();
369+
// Invalidate caches for data that changes after merge
370+
// PR cache is already invalidated by mergePR, but also clear timeline
371+
github.invalidateCache(`pr:${owner}/${repo}/${pr.number}:timeline`);
372+
373+
// Refetch the PR to get merged state and update the store
374+
const [updatedPR, updatedTimeline] = await Promise.all([
375+
github.getPR(owner, repo, pr.number),
376+
github
377+
.getPRTimeline(owner, repo, pr.number)
378+
.catch(() => [] as TimelineEvent[]),
379+
]);
380+
381+
store.setPr(updatedPR);
382+
setTimeline(updatedTimeline);
349383
} catch (e) {
350384
setMergeError(e instanceof Error ? e.message : "Failed to merge");
351385
} finally {
352386
setMerging(false);
353387
}
354-
}, [github, owner, repo, pr.number, mergeMethod, track]);
388+
}, [github, owner, repo, pr.number, mergeMethod, track, store]);
355389

356390
const handleApproveWorkflows = useCallback(async () => {
357391
setApprovingWorkflows(true);
358392
try {
393+
// Track which workflows we're approving to filter out stale API responses
394+
const approvedIds = workflowRunsAwaitingApproval.map((run) => run.id);
395+
for (const id of approvedIds) {
396+
recentlyApprovedWorkflowIds.current.add(id);
397+
}
398+
399+
// Optimistically clear the UI immediately
400+
setWorkflowRunsAwaitingApproval([]);
401+
359402
// Approve all workflow runs awaiting approval
360403
await Promise.all(
361-
workflowRunsAwaitingApproval.map((run) =>
362-
github.approveWorkflowRun(owner, repo, run.id)
363-
)
404+
approvedIds.map((id) => github.approveWorkflowRun(owner, repo, id))
364405
);
365-
// Clear the list after approving
366-
setWorkflowRunsAwaitingApproval([]);
367-
// Refresh checks to get updated status
406+
407+
// Refresh checks to get updated status (recently approved IDs will be filtered out)
368408
await fetchChecks();
369409
} catch (e) {
370410
console.error("Failed to approve workflows:", e);
411+
// On error, clear the recently approved tracking and re-fetch to restore actual state
412+
recentlyApprovedWorkflowIds.current.clear();
413+
await fetchChecks();
371414
} finally {
372415
setApprovingWorkflows(false);
373416
}
@@ -923,12 +966,33 @@ export const PROverview = memo(function PROverview() {
923966
// Build unified timeline
924967
type TimelineEntry =
925968
| { type: "comment"; data: IssueComment; date: Date }
926-
| { type: "review"; data: Review; date: Date }
969+
| {
970+
type: "review";
971+
data: Review;
972+
threads: ReviewThread[];
973+
date: Date;
974+
}
927975
| { type: "event"; data: TimelineEvent; date: Date }
928976
| { type: "thread"; data: ReviewThread; date: Date };
929977

930978
const entries: TimelineEntry[] = [];
931979

980+
// Build a map of review database ID -> threads that belong to it
981+
const threadsByReviewId = new Map<number, ReviewThread[]>();
982+
const orphanedThreads: ReviewThread[] = [];
983+
984+
reviewThreads.forEach((thread) => {
985+
const reviewId = thread.pullRequestReview?.databaseId;
986+
if (reviewId) {
987+
const existing = threadsByReviewId.get(reviewId) || [];
988+
existing.push(thread);
989+
threadsByReviewId.set(reviewId, existing);
990+
} else {
991+
// Thread without associated review (shouldn't happen often)
992+
orphanedThreads.push(thread);
993+
}
994+
});
995+
932996
// Add comments
933997
conversation.forEach((comment) => {
934998
entries.push({
@@ -938,26 +1002,31 @@ export const PROverview = memo(function PROverview() {
9381002
});
9391003
});
9401004

941-
// Add ALL reviews to timeline - show APPROVED/CHANGES_REQUESTED always, COMMENTED only if they have a body
942-
// Note: we use `reviews` not `latestReviews` because latestReviews only keeps one review per user
1005+
// Add ALL reviews to timeline with their associated threads
1006+
// Show APPROVED/CHANGES_REQUESTED always, COMMENTED only if they have a body OR associated threads
9431007
reviews
944-
.filter(
945-
(r) =>
946-
r.submitted_at &&
947-
(r.body ||
948-
r.state === "APPROVED" ||
949-
r.state === "CHANGES_REQUESTED")
950-
)
1008+
.filter((r) => {
1009+
if (!r.submitted_at) return false;
1010+
const hasThreads =
1011+
(threadsByReviewId.get(r.id)?.length ?? 0) > 0;
1012+
return (
1013+
r.body ||
1014+
r.state === "APPROVED" ||
1015+
r.state === "CHANGES_REQUESTED" ||
1016+
hasThreads
1017+
);
1018+
})
9511019
.forEach((review) => {
9521020
entries.push({
9531021
type: "review",
9541022
data: review,
1023+
threads: threadsByReviewId.get(review.id) || [],
9551024
date: new Date(review.submitted_at!),
9561025
});
9571026
});
9581027

959-
// Add review threads (inline code comments)
960-
reviewThreads.forEach((thread) => {
1028+
// Add orphaned threads (threads without a matching review in our list)
1029+
orphanedThreads.forEach((thread) => {
9611030
const firstComment = thread.comments.nodes[0];
9621031
if (firstComment) {
9631032
entries.push({
@@ -1041,10 +1110,23 @@ export const PROverview = memo(function PROverview() {
10411110
}
10421111
if (entry.type === "review") {
10431112
return (
1044-
<ReviewBox
1045-
key={`review-${entry.data.id}`}
1046-
review={entry.data}
1047-
/>
1113+
<div key={`review-${entry.data.id}`}>
1114+
<ReviewBox review={entry.data} />
1115+
{/* Render associated threads under the review */}
1116+
{entry.threads.map((thread) => (
1117+
<ReviewThreadBox
1118+
key={`thread-${thread.id}`}
1119+
thread={thread}
1120+
owner={owner}
1121+
repo={repo}
1122+
onReply={handleReplyToThread}
1123+
onResolve={handleResolveThread}
1124+
onUnresolve={handleUnresolveThread}
1125+
canWrite={canWrite}
1126+
currentUser={currentUser}
1127+
/>
1128+
))}
1129+
</div>
10481130
);
10491131
}
10501132
if (entry.type === "event") {
@@ -1057,6 +1139,7 @@ export const PROverview = memo(function PROverview() {
10571139
);
10581140
}
10591141
if (entry.type === "thread") {
1142+
// Orphaned thread (no associated review)
10601143
return (
10611144
<ReviewThreadBox
10621145
key={`thread-${entry.data.id}`}
@@ -2782,7 +2865,9 @@ function MergeSection({
27822865
alt={review.user?.login}
27832866
className="w-5 h-5 rounded-full"
27842867
/>
2785-
<span className="text-sm">{review.user?.login}</span>
2868+
<span className="text-sm">
2869+
{review.user?.login ?? ""}
2870+
</span>
27862871
<ReviewStateIcon state={review.state} showTooltip />
27872872
</div>
27882873
))}

src/browser/contexts/github.tsx

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ export interface ReviewThread {
108108
id: string;
109109
isResolved: boolean;
110110
resolvedBy: { login: string; avatarUrl: string } | null;
111+
// The review this thread belongs to (from first comment)
112+
pullRequestReview: {
113+
databaseId: number;
114+
author: { login: string; avatarUrl: string } | null;
115+
} | null;
111116
comments: {
112117
nodes: Array<{
113118
id: string;
@@ -2298,12 +2303,39 @@ function createGitHubStore() {
22982303
}> {
22992304
if (!batcher) throw new Error("Not initialized");
23002305

2306+
// Raw GraphQL response type (comments include pullRequestReview)
2307+
interface RawReviewThread {
2308+
id: string;
2309+
isResolved: boolean;
2310+
resolvedBy: { login: string; avatarUrl: string } | null;
2311+
comments: {
2312+
nodes: Array<{
2313+
id: string;
2314+
databaseId: number;
2315+
body: string;
2316+
path: string;
2317+
line: number | null;
2318+
originalLine: number | null;
2319+
startLine: number | null;
2320+
diffHunk: string | null;
2321+
author: { login: string; avatarUrl: string } | null;
2322+
createdAt: string;
2323+
updatedAt: string;
2324+
replyTo: { databaseId: number } | null;
2325+
pullRequestReview: {
2326+
databaseId: number;
2327+
author: { login: string; avatarUrl: string } | null;
2328+
} | null;
2329+
}>;
2330+
};
2331+
}
2332+
23012333
const data = await batcher.query<{
23022334
repository: {
23032335
viewerPermission: string | null;
23042336
pullRequest: {
23052337
viewerCanMergeAsAdmin: boolean;
2306-
reviewThreads: { nodes: ReviewThread[] };
2338+
reviewThreads: { nodes: RawReviewThread[] };
23072339
};
23082340
};
23092341
}>(
@@ -2332,6 +2364,10 @@ function createGitHubStore() {
23322364
createdAt
23332365
updatedAt
23342366
replyTo { databaseId }
2367+
pullRequestReview {
2368+
databaseId
2369+
author { login avatarUrl }
2370+
}
23352371
}
23362372
}
23372373
}
@@ -2343,8 +2379,19 @@ function createGitHubStore() {
23432379
{ owner, repo, number }
23442380
);
23452381

2382+
// Extract pullRequestReview from first comment into thread object
2383+
const threads = data.repository.pullRequest.reviewThreads.nodes.map(
2384+
(thread) => {
2385+
const firstComment = thread.comments.nodes[0];
2386+
return {
2387+
...thread,
2388+
pullRequestReview: firstComment?.pullRequestReview ?? null,
2389+
};
2390+
}
2391+
);
2392+
23462393
return {
2347-
threads: data.repository.pullRequest.reviewThreads.nodes,
2394+
threads,
23482395
viewerPermission: data.repository.viewerPermission,
23492396
viewerCanMergeAsAdmin: data.repository.pullRequest.viewerCanMergeAsAdmin,
23502397
};

0 commit comments

Comments
 (0)