refactor: make GitHub PR status more reliable and up to date (#613)

* fix: avoid incorrect PR status on default branch

Hide sidebar PR status when current branch is the repo default branch.
Tighten PR fallback matching to avoid picking unrelated PRs with the same branch name.

* fix: make sidebar PR status more reliable

Resolve PRs more reliably across remotes and repo networks.
Persist sidebar PR status across tab reloads while keeping updates fresh.
Reduce noisy GitHub errors and correct blocked styling in PR view.

* fix: correct blocked PR styling in sidebar

Stop treating missing merge permissions as a blocked PR state in the sidebar.
Align sidebar PR status colors and labels with the main PR view.

* refactor: make GitHub PR status updates feel more current

Refresh PR state on focus, visibility, and repo changes
Add follow-up PR refreshes after create, merge, ready, and update actions
Document GitHub PR resolution, consumers, polling, and triggers
This commit is contained in:
Bohdan Triapitsyn
2026-03-11 23:52:31 +02:00
committed by GitHub
parent 180cd57661
commit d1650343b1
9 changed files with 1462 additions and 584 deletions
@@ -100,7 +100,8 @@ const getPrVisualState = (status: GitHubPullRequestStatus | null): PrVisualState
return 'draft';
}
const checksFailed = status?.checks?.state === 'failure';
const notMergeable = status?.canMerge === false || pr.mergeable === false;
const mergeableState = typeof pr.mergeableState === 'string' ? pr.mergeableState : '';
const notMergeable = pr.mergeable === false || mergeableState === 'blocked' || mergeableState === 'dirty';
if (checksFailed || notMergeable) {
return 'blocked';
}
@@ -847,8 +848,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const result = new Map<string, PrIndicator>();
Object.values(prStatusEntries).forEach((entry) => {
const directory = normalizePath(entry.params?.directory ?? null);
const branch = entry.params?.branch?.trim();
const directory = normalizePath(entry.params?.directory ?? entry.identity?.directory ?? null);
const branch = entry.params?.branch?.trim() ?? entry.identity?.branch?.trim();
if (!directory || !branch) {
return;
}
@@ -232,9 +232,9 @@ export function SessionGroupSection(props: Props): React.ReactNode {
].filter((item): item is string => Boolean(item)).join(', ')
: null;
const mergeabilityLabel = prIndicator && prIndicator.state === 'open'
? (prIndicator.canMerge === true
? 'Mergeable'
: (prIndicator.canMerge === false ? 'Conflicts or blocked' : null))
? (prIndicator.mergeableState === 'blocked' || prIndicator.mergeableState === 'dirty'
? 'Conflicts or blocked'
: (prIndicator.mergeableState === 'clean' || prIndicator.canMerge === true ? 'Mergeable' : null))
: null;
const mergeStateLabel = prIndicator && prIndicator.state === 'open' && prIndicator.mergeableState
? `Merge state: ${prIndicator.mergeableState}`
@@ -93,13 +93,16 @@ const getPrVisualState = (status: GitHubPullRequestStatus | null): 'draft' | 'op
return 'draft';
}
const checksFailed = status?.checks?.state === 'failure';
const notMergeable = status?.canMerge === false || pr.mergeable === false;
const mergeableState = typeof pr.mergeableState === 'string' ? pr.mergeableState : '';
const notMergeable = pr.mergeable === false || mergeableState === 'blocked' || mergeableState === 'dirty';
if (checksFailed || notMergeable) {
return 'blocked';
}
return 'open';
};
const PR_ACTION_REFRESH_DELAYS_MS = [2_000, 5_000] as const;
const branchToTitle = (branch: string): string => {
return branch
.replace(/^refs\/heads\//, '')
@@ -449,6 +452,7 @@ export const PullRequestSection: React.FC<{
const lastSyncedPrNumberRef = React.useRef<number | null>(null);
const didUserOverrideRemoteRef = React.useRef(false);
const autoRemoteProbeDoneRef = React.useRef<Set<string>>(new Set());
const pendingActionRefreshTimersRef = React.useRef<number[]>([]);
const canShow = Boolean(directory && branch && baseBranch && branch !== baseBranch);
@@ -957,6 +961,15 @@ export const PullRequestSection: React.FC<{
await refreshPrStatus(prStatusKey, options);
}, [prStatusKey, refreshPrStatus]);
const scheduleActionRefresh = React.useCallback(() => {
pendingActionRefreshTimersRef.current.forEach((timerId) => {
window.clearTimeout(timerId);
});
pendingActionRefreshTimersRef.current = PR_ACTION_REFRESH_DELAYS_MS.map((delayMs) => window.setTimeout(() => {
void refresh({ force: true, silent: true, markInitialResolved: true });
}, delayMs));
}, [refresh]);
// Refetch PR status when selected remote changes
const handleRemoteChange = React.useCallback((remote: GitRemote) => {
didUserOverrideRemoteRef.current = true;
@@ -1069,6 +1082,18 @@ export const PullRequestSection: React.FC<{
void refresh({ force: true, silent: true, markInitialResolved: true });
}, [canShow, refresh, selectedRemote?.name]);
React.useEffect(() => {
const resolvedRemoteName = status?.resolvedRemoteName?.trim();
if (!resolvedRemoteName || didUserOverrideRemoteRef.current) {
return;
}
const resolvedRemote = remotes.find((candidate) => candidate.name === resolvedRemoteName);
if (!resolvedRemote) {
return;
}
setSelectedRemote((prev) => (prev?.name === resolvedRemote.name ? prev : resolvedRemote));
}, [remotes, status?.resolvedRemoteName]);
React.useEffect(() => {
const isTerminal = status?.pr?.state === 'closed' || status?.pr?.state === 'merged';
const lastRefreshAt = statusEntry?.lastRefreshAt ?? 0;
@@ -1116,6 +1141,16 @@ export const PullRequestSection: React.FC<{
});
}, [snapshotKey, title, body, draft, additionalContext, targetBaseBranch, selectedRemote?.name, directory, branch]);
React.useEffect(() => {
const pendingActionRefreshTimers = pendingActionRefreshTimersRef.current;
return () => {
pendingActionRefreshTimers.forEach((timerId) => {
window.clearTimeout(timerId);
});
pendingActionRefreshTimersRef.current = [];
};
}, []);
const generateDescription = React.useCallback(async () => {
if (isGenerating) return;
if (!directory) return;
@@ -1182,13 +1217,14 @@ export const PullRequestSection: React.FC<{
toast.success('PR created');
updatePrStatus(prStatusKey, (prev) => (prev ? { ...prev, pr } : prev));
await refresh({ force: true });
scheduleActionRefresh();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to create PR', { description: message });
} finally {
setIsCreating(false);
}
}, [body, branch, directory, draft, github, prStatusKey, refresh, selectedRemote, targetBaseBranch, title, updatePrStatus]);
}, [body, branch, directory, draft, github, prStatusKey, refresh, scheduleActionRefresh, selectedRemote, targetBaseBranch, title, updatePrStatus]);
const mergePr = React.useCallback(async (pr: GitHubPullRequest) => {
if (!github?.prMerge) {
@@ -1204,6 +1240,7 @@ export const PullRequestSection: React.FC<{
toast.message('PR not merged', { description: result.message || 'Not mergeable' });
}
await refresh({ force: true });
scheduleActionRefresh();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Merge failed', { description: message });
@@ -1213,7 +1250,7 @@ export const PullRequestSection: React.FC<{
} finally {
setIsMerging(false);
}
}, [directory, github, mergeMethod, refresh]);
}, [directory, github, mergeMethod, refresh, scheduleActionRefresh]);
const markReady = React.useCallback(async (pr: GitHubPullRequest) => {
if (!github?.prReady) {
@@ -1225,6 +1262,7 @@ export const PullRequestSection: React.FC<{
await github.prReady({ directory, number: pr.number });
toast.success('Marked ready for review');
await refresh({ force: true });
scheduleActionRefresh();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to mark ready', { description: message });
@@ -1234,7 +1272,7 @@ export const PullRequestSection: React.FC<{
} finally {
setIsMarkingReady(false);
}
}, [directory, github, refresh]);
}, [directory, github, refresh, scheduleActionRefresh]);
const updatePr = React.useCallback(async (pr: GitHubPullRequest) => {
if (!github?.prUpdate) {
@@ -1268,13 +1306,14 @@ export const PullRequestSection: React.FC<{
setIsEditingPr(false);
toast.success('PR updated');
await refresh({ force: true });
scheduleActionRefresh();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to update PR', { description: message });
} finally {
setIsUpdating(false);
}
}, [directory, editBody, editTitle, github, prStatusKey, refresh, updatePrStatus]);
}, [directory, editBody, editTitle, github, prStatusKey, refresh, scheduleActionRefresh, updatePrStatus]);
if (!canShow) {
return null;