Skip to content

Commit 7332a51

Browse files
committed
Fix PR closing feature
1 parent bffcdd0 commit 7332a51

File tree

7 files changed

+758
-26
lines changed

7 files changed

+758
-26
lines changed

bun.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"@radix-ui/react-checkbox": "^1.3.3",
99
"@radix-ui/react-dialog": "^1.1.15",
1010
"@radix-ui/react-hover-card": "^1.1.15",
11+
"@radix-ui/react-popover": "^1.1.15",
1112
"@radix-ui/react-radio-group": "^1.3.8",
1213
"@radix-ui/react-tabs": "^1.1.13",
1314
"rehype-highlight": "^7.0.2",
@@ -306,6 +307,8 @@
306307

307308
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
308309

310+
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
311+
309312
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
310313

311314
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
@@ -1322,6 +1325,8 @@
13221325

13231326
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
13241327

1328+
"@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
1329+
13251330
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
13261331

13271332
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"@radix-ui/react-checkbox": "^1.3.3",
9292
"@radix-ui/react-dialog": "^1.1.15",
9393
"@radix-ui/react-hover-card": "^1.1.15",
94+
"@radix-ui/react-popover": "^1.1.15",
9495
"@radix-ui/react-radio-group": "^1.3.8",
9596
"@radix-ui/react-tabs": "^1.1.13",
9697
"rehype-highlight": "^7.0.2",

src/browser/components/pr-overview.tsx

Lines changed: 117 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,24 @@ export const PROverview = memo(function PROverview() {
120120

121121
// Action loading states
122122
const [closingPR, setClosingPR] = useState(false);
123+
const [reopeningPR, setReopeningPR] = useState(false);
124+
const [deletingBranch, setDeletingBranch] = useState(false);
125+
const [branchDeleted, setBranchDeleted] = useState(false);
123126
const [convertingToDraft, setConvertingToDraft] = useState(false);
124127
const [markingReady, setMarkingReady] = useState(false);
125128
const [assigningSelf, setAssigningSelf] = useState(false);
126129

127-
// Repo permissions - check if user can push (merge) and if repo is archived
130+
// Viewer permissions from GraphQL (more reliable than REST)
131+
const [viewerPermission, setViewerPermission] = useState<string | null>(null);
132+
133+
// Repo permissions - use GraphQL viewerPermission as primary source
128134
const isArchived = pr.base?.repo?.archived ?? false;
129-
const canPush = pr.base?.repo?.permissions?.push ?? false;
135+
// WRITE, MAINTAIN, or ADMIN permissions allow merging
136+
const canPush =
137+
viewerPermission === "ADMIN" ||
138+
viewerPermission === "MAINTAIN" ||
139+
viewerPermission === "WRITE" ||
140+
pr.base?.repo?.permissions?.push === true;
130141
const canMergeRepo = canWrite && canPush && !isArchived;
131142

132143
// Reviewers and Assignees state
@@ -192,7 +203,7 @@ export const PROverview = memo(function PROverview() {
192203
conversationData,
193204
commitsData,
194205
timelineData,
195-
reviewThreadsData,
206+
reviewThreadsResult,
196207
] = await Promise.all([
197208
github
198209
.getPRReviews(owner, repo, pr.number)
@@ -220,7 +231,10 @@ export const PROverview = memo(function PROverview() {
220231
.catch(() => [] as TimelineEvent[]),
221232
github
222233
.getReviewThreads(owner, repo, pr.number)
223-
.catch(() => [] as ReviewThread[]),
234+
.catch(() => ({
235+
threads: [] as ReviewThread[],
236+
viewerPermission: null,
237+
})),
224238
]);
225239

226240
setReviews(reviewsData);
@@ -229,7 +243,8 @@ export const PROverview = memo(function PROverview() {
229243
setConversation(conversationData);
230244
setCommits(commitsData);
231245
setTimeline(timelineData);
232-
setReviewThreads(reviewThreadsData);
246+
setReviewThreads(reviewThreadsResult.threads);
247+
setViewerPermission(reviewThreadsResult.viewerPermission);
233248
} finally {
234249
setLoading(false);
235250
}
@@ -389,6 +404,42 @@ export const PROverview = memo(function PROverview() {
389404
}
390405
}, [github, owner, repo, pr.number, refetchPR]);
391406

407+
const handleReopenPR = useCallback(async () => {
408+
setReopeningPR(true);
409+
try {
410+
await github.reopenPR(owner, repo, pr.number);
411+
await refetchPR();
412+
} catch (error) {
413+
console.error("Failed to reopen PR:", error);
414+
} finally {
415+
setReopeningPR(false);
416+
}
417+
}, [github, owner, repo, pr.number, refetchPR]);
418+
419+
const handleDeleteBranch = useCallback(async () => {
420+
if (
421+
!window.confirm(
422+
`Are you sure you want to delete the branch "${pr.head.ref}"?`
423+
)
424+
) {
425+
return;
426+
}
427+
428+
setDeletingBranch(true);
429+
try {
430+
await github.deleteBranch(
431+
pr.head.repo?.owner?.login ?? owner,
432+
pr.head.repo?.name ?? repo,
433+
pr.head.ref
434+
);
435+
setBranchDeleted(true);
436+
} catch (error) {
437+
console.error("Failed to delete branch:", error);
438+
} finally {
439+
setDeletingBranch(false);
440+
}
441+
}, [github, owner, repo, pr.head.ref, pr.head.repo]);
442+
392443
const handleRequestReviewer = useCallback(
393444
async (login: string) => {
394445
try {
@@ -857,6 +908,67 @@ export const PROverview = memo(function PROverview() {
857908
</>
858909
)}
859910

911+
{/* Closed with unmerged commits - show for closed, unmerged PRs */}
912+
{pr.state === "closed" && !pr.merged && (
913+
<div className="border border-border rounded-md overflow-hidden">
914+
<div className="flex items-start gap-3 p-4 bg-card/30">
915+
<div className="p-2 rounded-full bg-purple-500/10 text-purple-400">
916+
<GitBranch className="w-5 h-5" />
917+
</div>
918+
<div className="flex-1 min-w-0">
919+
<h3 className="font-semibold">
920+
Closed with unmerged commits
921+
</h3>
922+
<p className="text-sm text-muted-foreground mt-1">
923+
This pull request is closed, but the{" "}
924+
<code className="px-1.5 py-0.5 bg-blue-500/20 text-blue-300 rounded text-xs">
925+
{pr.head.ref}
926+
</code>{" "}
927+
branch has unmerged commits.
928+
</p>
929+
</div>
930+
{canMergeRepo && !branchDeleted && (
931+
<button
932+
onClick={handleDeleteBranch}
933+
disabled={deletingBranch}
934+
className="shrink-0 px-4 py-2 border border-border text-sm font-medium rounded-md hover:bg-muted/50 transition-colors disabled:opacity-50"
935+
>
936+
{deletingBranch ? (
937+
<span className="flex items-center gap-2">
938+
<Loader2 className="w-4 h-4 animate-spin" />
939+
Deleting...
940+
</span>
941+
) : (
942+
"Delete branch"
943+
)}
944+
</button>
945+
)}
946+
{branchDeleted && (
947+
<span className="shrink-0 px-4 py-2 text-sm text-green-400 flex items-center gap-2">
948+
<Check className="w-4 h-4" />
949+
Branch deleted
950+
</span>
951+
)}
952+
</div>
953+
{canMergeRepo && (
954+
<div className="px-4 py-3 border-t border-border bg-card/10 flex items-center justify-end">
955+
<button
956+
onClick={handleReopenPR}
957+
disabled={reopeningPR}
958+
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700 transition-colors disabled:opacity-50"
959+
>
960+
{reopeningPR ? (
961+
<Loader2 className="w-4 h-4 animate-spin" />
962+
) : (
963+
<GitPullRequest className="w-4 h-4" />
964+
)}
965+
{reopeningPR ? "Reopening..." : "Reopen pull request"}
966+
</button>
967+
</div>
968+
)}
969+
</div>
970+
)}
971+
860972
{/* Add a comment - only show when user can write (comments allowed even without push) */}
861973
{canWrite ? (
862974
<div className="flex gap-3">

src/browser/contexts/github.tsx

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,35 @@ function createGitHubStore() {
873873
return promise;
874874
}
875875

876+
async function searchUsers(query: string) {
877+
if (!octokit) throw new Error("Not initialized");
878+
879+
const cacheKey = `search:users:${query}`;
880+
881+
type UserSearchResult = Awaited<
882+
ReturnType<typeof octokit.request<"GET /search/users">>
883+
>["data"];
884+
885+
const cached = cache.get<UserSearchResult>(cacheKey);
886+
if (cached) return cached;
887+
888+
const pending = cache.getPending<UserSearchResult>(cacheKey);
889+
if (pending) return pending;
890+
891+
const promise = octokit
892+
.request("GET /search/users", {
893+
q: query,
894+
per_page: 8,
895+
})
896+
.then((res) => {
897+
cache.set(cacheKey, res.data);
898+
return res.data;
899+
});
900+
901+
cache.setPending(cacheKey, promise);
902+
return promise;
903+
}
904+
876905
async function getPR(
877906
owner: string,
878907
repo: string,
@@ -1685,6 +1714,16 @@ function createGitHubStore() {
16851714
return data;
16861715
}
16871716

1717+
async function deleteBranch(owner: string, repo: string, branch: string) {
1718+
if (!octokit) throw new Error("Not initialized");
1719+
1720+
await octokit.request("DELETE /repos/{owner}/{repo}/git/refs/{ref}", {
1721+
owner,
1722+
repo,
1723+
ref: `heads/${branch}`,
1724+
});
1725+
}
1726+
16881727
async function getPRConversation(
16891728
owner: string,
16901729
repo: string,
@@ -1906,11 +1945,12 @@ function createGitHubStore() {
19061945
owner: string,
19071946
repo: string,
19081947
number: number
1909-
): Promise<ReviewThread[]> {
1948+
): Promise<{ threads: ReviewThread[]; viewerPermission: string | null }> {
19101949
if (!batcher) throw new Error("Not initialized");
19111950

19121951
const data = await batcher.query<{
19131952
repository: {
1953+
viewerPermission: string | null;
19141954
pullRequest: {
19151955
reviewThreads: { nodes: ReviewThread[] };
19161956
};
@@ -1919,6 +1959,7 @@ function createGitHubStore() {
19191959
`
19201960
query ($owner: String!, $repo: String!, $number: Int!) {
19211961
repository(owner: $owner, name: $repo) {
1962+
viewerPermission
19221963
pullRequest(number: $number) {
19231964
reviewThreads(first: 100) {
19241965
nodes {
@@ -1950,7 +1991,10 @@ function createGitHubStore() {
19501991
{ owner, repo, number }
19511992
);
19521993

1953-
return data.repository.pullRequest.reviewThreads.nodes;
1994+
return {
1995+
threads: data.repository.pullRequest.reviewThreads.nodes,
1996+
viewerPermission: data.repository.viewerPermission,
1997+
};
19541998
}
19551999

19562000
async function resolveThread(threadId: string): Promise<void> {
@@ -2170,6 +2214,7 @@ function createGitHubStore() {
21702214
// API methods
21712215
searchPRs,
21722216
searchRepos,
2217+
searchUsers,
21732218
getPR,
21742219
getPRFiles,
21752220
getPRComments,
@@ -2198,6 +2243,7 @@ function createGitHubStore() {
21982243
updateBranch,
21992244
closePR,
22002245
reopenPR,
2246+
deleteBranch,
22012247
// Reactions
22022248
getIssueReactions,
22032249
addIssueReaction,

src/browser/contexts/pr-review.tsx

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ import {
2323
} from "@/browser/contexts/github";
2424
import { useTelemetry } from "@/browser/contexts/telemetry";
2525
import { diffService } from "@/browser/lib/diff";
26+
import {
27+
MentionSuggestionsProvider,
28+
type MentionUser,
29+
} from "@/browser/ui/markdown";
2630

2731
// ============================================================================
2832
// File Sorting (match file tree order)
@@ -1634,9 +1638,60 @@ export function PRReviewProvider({
16341638
storeRef.current?.setComments(comments);
16351639
}, [comments]);
16361640

1641+
// Extract relevant users for @mention suggestions
1642+
// Priority: PR participants (author, reviewers, assignees, commenters)
1643+
const suggestedUsers = useMemo(() => {
1644+
const seen = new Set<string>();
1645+
const users: MentionUser[] = [];
1646+
1647+
const addUser = (
1648+
login: string | undefined,
1649+
avatar_url: string | undefined
1650+
) => {
1651+
if (!login || seen.has(login.toLowerCase())) return;
1652+
seen.add(login.toLowerCase());
1653+
users.push({
1654+
login,
1655+
avatar_url: avatar_url || `https://github.com/${login}.png`,
1656+
});
1657+
};
1658+
1659+
// PR author first
1660+
if (pr.user) {
1661+
addUser(pr.user.login, pr.user.avatar_url);
1662+
}
1663+
1664+
// Assignees
1665+
for (const assignee of pr.assignees || []) {
1666+
addUser(assignee.login, assignee.avatar_url);
1667+
}
1668+
1669+
// Requested reviewers (can be users or teams)
1670+
for (const reviewer of pr.requested_reviewers || []) {
1671+
if ("login" in reviewer) {
1672+
addUser(reviewer.login, reviewer.avatar_url);
1673+
}
1674+
}
1675+
1676+
// Commenters (from review comments)
1677+
for (const comment of comments) {
1678+
if (comment.user) {
1679+
addUser(comment.user.login, comment.user.avatar_url);
1680+
}
1681+
}
1682+
1683+
return users;
1684+
}, [pr, comments]);
1685+
16371686
return (
16381687
<PRReviewContext.Provider value={storeRef.current}>
1639-
{children}
1688+
<MentionSuggestionsProvider
1689+
suggestedUsers={suggestedUsers}
1690+
owner={owner}
1691+
repo={repo}
1692+
>
1693+
{children}
1694+
</MentionSuggestionsProvider>
16401695
</PRReviewContext.Provider>
16411696
);
16421697
}

0 commit comments

Comments
 (0)