Skip to content

Commit 168342b

Browse files
committed
feat: show PR stacks in a new Stack tab
Add a Stack tab (after Files Changed) on stacked PRs that lists every PR in the stack and lets you jump between them. Also make the IndexedDB-backed caches self-heal when their object store is missing, instead of failing and discarding the cache on every load.
1 parent e2b528f commit 168342b

9 files changed

Lines changed: 463 additions & 3 deletions

File tree

src/browser/components/pr-overview.tsx

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import { parseDiffCached, type ParsedDiff } from "../lib/diff";
6262
import type { ReviewComment } from "@/api/types";
6363
import { useQuery } from "@tanstack/react-query";
6464
import { queries } from "../lib/queries";
65+
import { useOpenPRReviewTab } from "../contexts/tabs";
6566
import {
6667
useGitHub,
6768
useGitHubReady,
@@ -76,6 +77,7 @@ import {
7677
type TimelineEvent,
7778
type ReviewThread,
7879
type PullRequest,
80+
type PullRequestStackData,
7981
type PushVersion,
8082
} from "../contexts/github";
8183
import { useCanWrite } from "../contexts/auth";
@@ -1367,6 +1369,15 @@ export const PROverview = memo(function PROverview() {
13671369
label="Files Changed"
13681370
count={files.length}
13691371
/>
1372+
{pr.stack && (
1373+
<TabButton
1374+
active={activeTab === "stack"}
1375+
onClick={() => setActiveTab("stack")}
1376+
icon={<GitBranch className="w-4 h-4" />}
1377+
label="Stack"
1378+
count={pr.stack.size}
1379+
/>
1380+
)}
13701381
</div>
13711382
</div>
13721383
</div>
@@ -2218,6 +2229,8 @@ export const PROverview = memo(function PROverview() {
22182229
refreshing={refreshingChecks}
22192230
/>
22202231
)}
2232+
2233+
{activeTab === "stack" && <StackTab />}
22212234
</div>
22222235

22232236
{/* Right Column - Sidebar */}
@@ -4750,6 +4763,119 @@ function CommitsTab({
47504763
);
47514764
}
47524765

4766+
// ============================================================================
4767+
// Stack Tab Component
4768+
// ============================================================================
4769+
4770+
function StackTab() {
4771+
const store = usePRReviewStore();
4772+
const pr = usePRReviewSelector((s) => s.pr);
4773+
const owner = usePRReviewSelector((s) => s.owner);
4774+
const repo = usePRReviewSelector((s) => s.repo);
4775+
const stackData = usePRReviewSelector((s) => s.stackData);
4776+
const stackLoading = usePRReviewSelector((s) => s.stackLoading);
4777+
const openPRReviewTab = useOpenPRReviewTab();
4778+
4779+
// Lazy-load the stack the first time the tab is opened
4780+
useEffect(() => {
4781+
store.loadStackData();
4782+
}, [store]);
4783+
4784+
if (stackLoading && !stackData) {
4785+
return (
4786+
<div className="flex items-center gap-2 p-4 text-sm text-muted-foreground">
4787+
<Loader2 className="w-4 h-4 animate-spin" />
4788+
Loading stack...
4789+
</div>
4790+
);
4791+
}
4792+
4793+
if (!stackData) {
4794+
return (
4795+
<p className="p-4 text-sm text-muted-foreground">
4796+
This pull request is not part of a stack.
4797+
</p>
4798+
);
4799+
}
4800+
4801+
return (
4802+
<div className="space-y-3">
4803+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
4804+
<GitBranch className="w-4 h-4" />
4805+
<span>
4806+
{stackData.size} PRs stacked on{" "}
4807+
<span className="font-mono text-foreground">
4808+
{stackData.baseRefName}
4809+
</span>
4810+
</span>
4811+
</div>
4812+
<div className="border border-border rounded-md overflow-hidden divide-y divide-border">
4813+
{stackData.entries.map((entry) => {
4814+
const { pullRequest } = entry;
4815+
const isCurrent = pullRequest.number === pr.number;
4816+
return (
4817+
<button
4818+
key={pullRequest.number}
4819+
onClick={() =>
4820+
openPRReviewTab(
4821+
owner,
4822+
repo,
4823+
pullRequest.number,
4824+
pullRequest.title
4825+
)
4826+
}
4827+
className={cn(
4828+
"flex items-center gap-3 p-3 w-full text-left",
4829+
isCurrent
4830+
? "bg-muted/50 cursor-default"
4831+
: "hover:bg-card/30 cursor-pointer"
4832+
)}
4833+
>
4834+
<span
4835+
className={cn(
4836+
"flex items-center justify-center w-6 h-6 rounded text-xs font-semibold shrink-0",
4837+
isCurrent
4838+
? "bg-orange-500/15 text-orange-500"
4839+
: "bg-muted text-muted-foreground"
4840+
)}
4841+
>
4842+
{entry.position}
4843+
</span>
4844+
<div className="flex-1 min-w-0">
4845+
<div className="flex items-baseline gap-2 min-w-0">
4846+
<span className="text-sm font-medium truncate">
4847+
{pullRequest.title}
4848+
</span>
4849+
<span className="text-xs text-muted-foreground shrink-0">
4850+
#{pullRequest.number}
4851+
</span>
4852+
{pullRequest.isDraft && (
4853+
<span className="text-xs text-muted-foreground border border-border rounded px-1 py-0.5 shrink-0">
4854+
Draft
4855+
</span>
4856+
)}
4857+
</div>
4858+
</div>
4859+
{isCurrent && (
4860+
<span className="text-xs text-orange-500 shrink-0">
4861+
current
4862+
</span>
4863+
)}
4864+
{pullRequest.merged ? (
4865+
<CheckCircle2 className="w-4 h-4 text-purple-500 shrink-0" />
4866+
) : pullRequest.state === "CLOSED" ? (
4867+
<XCircle className="w-4 h-4 text-red-500 shrink-0" />
4868+
) : (
4869+
<GitPullRequest className="w-4 h-4 text-green-500 shrink-0" />
4870+
)}
4871+
</button>
4872+
);
4873+
})}
4874+
</div>
4875+
</div>
4876+
);
4877+
}
4878+
47534879
// ============================================================================
47544880
// Checks Tab Component
47554881
// ============================================================================

src/browser/contexts/github.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,26 @@ export interface PushVersion {
150150
beforeSha?: string;
151151
}
152152

153+
// GraphQL-only types for GitHub's stacked PRs feature
154+
export interface PullRequestStackEntry {
155+
position: number;
156+
pullRequest: {
157+
number: number;
158+
title: string;
159+
state: string;
160+
merged: boolean;
161+
isDraft: boolean;
162+
};
163+
}
164+
165+
export interface PullRequestStackData {
166+
id: string;
167+
number: number;
168+
size: number;
169+
baseRefName: string;
170+
entries: PullRequestStackEntry[];
171+
}
172+
153173
export function groupCommitsIntoVersions(
154174
commits: PRCommit[],
155175
maxGapMinutes = 2
@@ -708,6 +728,17 @@ function createGitHubStore() {
708728
return queryClient.fetchQuery(queries.pullRequest(owner, repo, number));
709729
}
710730

731+
function getPRStack(
732+
owner: string,
733+
repo: string,
734+
number: number
735+
): Promise<PullRequestStackData | null> {
736+
if (!octokit) throw new Error("Not initialized");
737+
return queryClient.fetchQuery(
738+
queries.pullRequestStack(owner, repo, number)
739+
);
740+
}
741+
711742
function getPRFiles(
712743
owner: string,
713744
repo: string,
@@ -2568,6 +2599,7 @@ function createGitHubStore() {
25682599
searchRepos,
25692600
searchUsers,
25702601
getPR,
2602+
getPRStack,
25712603
getPRFiles,
25722604
getPRFilesForRange,
25732605
getCommitFiles,

src/browser/contexts/pr-review/index.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ function createMockGitHubStore(): GitHubStore {
4141
}),
4242
invalidatePR: () => {},
4343
getPR: async () => createMockPR(),
44+
getPRStack: async () => null,
4445
mergePR: async () => ({ merged: true }),
4546
mergePRAsync: async () => ({
4647
status: "merged",
@@ -766,6 +767,7 @@ function createMockGitHubStoreWithVersions(
766767
}),
767768
invalidatePR: () => {},
768769
getPR: async () => createMockPR(),
770+
getPRStack: async () => null,
769771
mergePR: async () => ({ merged: true }),
770772
mergePRAsync: async () => ({
771773
status: "merged",
@@ -1097,6 +1099,95 @@ test("mergePR sets mergeError when async merge fails", async () => {
10971099
expect(state.prInMergeQueue).toBe(false);
10981100
});
10991101

1102+
// ============================================================================
1103+
// loadStackData
1104+
// ============================================================================
1105+
1106+
test("loadStackData fetches and stores the stack for stacked PRs", async () => {
1107+
const stackData = {
1108+
id: "PRS_1",
1109+
number: 3,
1110+
size: 2,
1111+
baseRefName: "master",
1112+
entries: [
1113+
{
1114+
position: 1,
1115+
pullRequest: {
1116+
number: 1,
1117+
title: "PR 1",
1118+
state: "OPEN",
1119+
merged: false,
1120+
isDraft: false,
1121+
},
1122+
},
1123+
{
1124+
position: 2,
1125+
pullRequest: {
1126+
number: 2,
1127+
title: "PR 2",
1128+
state: "OPEN",
1129+
merged: false,
1130+
isDraft: false,
1131+
},
1132+
},
1133+
],
1134+
};
1135+
const github = {
1136+
...createMockGitHubStore(),
1137+
getPRStack: async () => stackData,
1138+
} as unknown as GitHubStore;
1139+
const store = new PRReviewStore(github, {
1140+
pr: createStackedMockPR(),
1141+
files: [],
1142+
comments: [],
1143+
owner: "test",
1144+
repo: "repo",
1145+
viewerPermission: "WRITE",
1146+
});
1147+
1148+
await store.loadStackData();
1149+
1150+
const state = store.getSnapshot();
1151+
expect(state.stackData).toEqual(stackData);
1152+
expect(state.stackLoading).toBe(false);
1153+
});
1154+
1155+
test("loadStackData skips fetching when PR is not in a stack", async () => {
1156+
let fetchCalled = false;
1157+
const github = {
1158+
...createMockGitHubStore(),
1159+
getPRStack: async () => {
1160+
fetchCalled = true;
1161+
return null;
1162+
},
1163+
} as unknown as GitHubStore;
1164+
const store = new PRReviewStore(github, {
1165+
pr: createMockPR(),
1166+
files: [],
1167+
comments: [],
1168+
owner: "test",
1169+
repo: "repo",
1170+
viewerPermission: "WRITE",
1171+
});
1172+
1173+
await store.loadStackData();
1174+
1175+
expect(fetchCalled).toBe(false);
1176+
const state = store.getSnapshot();
1177+
expect(state.stackData).toBeNull();
1178+
expect(state.stackLoading).toBe(false);
1179+
});
1180+
1181+
test("setOverviewActiveTab switches to the stack tab", () => {
1182+
const store = createStore();
1183+
1184+
store.setOverviewActiveTab("stack");
1185+
1186+
const state = store.getSnapshot();
1187+
expect(state.overviewActiveTab).toBe("stack");
1188+
expect(state.showOverview).toBe(true);
1189+
});
1190+
11001191
// ============================================================================
11011192
// Conversation / Timeline events
11021193
// ============================================================================

src/browser/contexts/pr-review/index.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
type TimelineEvent,
3333
type ReviewThread,
3434
type PushVersion,
35+
type PullRequestStackData,
3536
groupCommitsIntoVersions,
3637
} from "@/browser/contexts/github";
3738
import { diffService } from "@/browser/lib/diff";
@@ -157,7 +158,7 @@ export interface WorkflowRunAwaitingApproval {
157158
// Merge method type
158159
export type MergeMethod = "merge" | "squash" | "rebase";
159160

160-
export type OverviewTab = "conversation" | "commits" | "checks";
161+
export type OverviewTab = "conversation" | "commits" | "checks" | "stack";
161162

162163
interface PRReviewState {
163164
// Core data
@@ -233,6 +234,10 @@ interface PRReviewState {
233234
/** Whether deferred version/push data has been loaded */
234235
versionDataLoaded: boolean;
235236

237+
// Stacked PRs (lazily loaded when the Stack tab is first opened)
238+
stackData: PullRequestStackData | null;
239+
stackLoading: boolean;
240+
236241
// Repository merge settings
237242
repoAllowMergeCommit: boolean;
238243
repoAllowSquashMerge: boolean;
@@ -648,6 +653,10 @@ export class PRReviewStore {
648653
repoHasMergeQueue: false,
649654
prInMergeQueue: false,
650655

656+
// Stacked PRs (lazily loaded)
657+
stackData: null,
658+
stackLoading: false,
659+
651660
// Merge state
652661
merging: false,
653662
mergeMethod: "squash",
@@ -3674,6 +3683,20 @@ export class PRReviewStore {
36743683
}
36753684
};
36763685

3686+
/** Load the PR stack (lazily, when the Stack tab is first opened). */
3687+
loadStackData = async (): Promise<void> => {
3688+
const { owner, repo, pr, stackData, stackLoading } = this.state;
3689+
if (stackData || stackLoading || !pr.stack) return;
3690+
3691+
this.set({ stackLoading: true });
3692+
try {
3693+
const data = await this.github.getPRStack(owner, repo, pr.number);
3694+
this.set({ stackData: data, stackLoading: false });
3695+
} catch {
3696+
this.set({ stackLoading: false });
3697+
}
3698+
};
3699+
36773700
/**
36783701
* Refresh just the checks data
36793702
*/

0 commit comments

Comments
 (0)