Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 63 additions & 3 deletions apps/dashboard/src/components/details/detail-activity.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { CommentIcon } from "@diffkit/icons";
import {
CommentIcon,
GitPullRequestClosedIcon,
GitPullRequestIcon,
} from "@diffkit/icons";
import {
MarkdownEditor,
type MentionCandidate,
Expand All @@ -7,7 +11,7 @@ import { toast } from "@diffkit/ui/components/sonner";
import { Spinner } from "@diffkit/ui/components/spinner";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { createComment } from "#/lib/github.functions";
import { createComment, updatePullState } from "#/lib/github.functions";
import {
type GitHubQueryScope,
githubQueryKeys,
Expand Down Expand Up @@ -42,15 +46,18 @@ export function DetailCommentBox({
issueNumber,
scope,
involvedUsers,
pullState,
}: {
owner: string;
repo: string;
issueNumber: number;
scope: GitHubQueryScope;
involvedUsers?: GitHubActor[];
pullState?: "open" | "closed";
}) {
const [value, setValue] = useState("");
const [isSending, setIsSending] = useState(false);
const [isTogglingState, setIsTogglingState] = useState(false);
const [mentionActivated, setMentionActivated] = useState(false);
const queryClient = useQueryClient();

Expand Down Expand Up @@ -121,6 +128,42 @@ export function DetailCommentBox({
}
};

const handleTogglePullState = async () => {
if (!pullState) return;
const newState = pullState === "open" ? "closed" : "open";
setIsTogglingState(true);
try {
if (value.trim()) {
await createComment({
data: { owner, repo, issueNumber, body: value.trim() },
});
setValue("");
}
const result = await updatePullState({
data: {
owner,
repo,
pullNumber: issueNumber,
state: newState,
},
});
if (result.ok) {
void queryClient.invalidateQueries({
queryKey: githubQueryKeys.all,
});
} else {
toast.error(result.error);
checkPermissionWarning(result, `${owner}/${repo}`);
}
} catch {
toast.error(
`Failed to ${pullState === "open" ? "close" : "reopen"} pull request`,
);
} finally {
setIsTogglingState(false);
}
};

return (
<div className="flex flex-col gap-2">
<MarkdownEditor
Expand All @@ -134,7 +177,24 @@ export function DetailCommentBox({
isLoading: collaboratorsQuery.isLoading && mentionActivated,
}}
/>
<div className="flex justify-end">
<div className="flex items-center justify-end gap-2">
{pullState && (
<button
type="button"
disabled={isTogglingState}
onClick={handleTogglePullState}
className="flex items-center gap-1.5 rounded-lg border bg-secondary px-3 py-1.5 text-xs font-medium text-secondary-foreground transition-opacity hover:bg-secondary/80 disabled:opacity-40"
>
{isTogglingState ? (
<Spinner size={12} />
) : pullState === "open" ? (
<GitPullRequestClosedIcon size={12} strokeWidth={2} />
) : (
<GitPullRequestIcon size={12} strokeWidth={2} />
)}
{pullState === "open" ? "Close PR" : "Re-open PR"}
</button>
)}
<button
type="button"
disabled={!value.trim() || isSending}
Expand Down
122 changes: 109 additions & 13 deletions apps/dashboard/src/components/pulls/detail/pull-detail-activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ export function PullDetailActivitySection({
owner={owner}
repo={repo}
pullNumber={pullNumber}
prTitle={pr.title}
firstCommitMessage={commits?.[0]?.message}
/>
</div>
)}
Expand All @@ -250,6 +252,11 @@ export function PullDetailActivitySection({
issueNumber={pullNumber}
scope={scope}
involvedUsers={getInvolvedUsers(pr, comments)}
pullState={
!pr.isMerged && (pr.state === "open" || pr.state === "closed")
? pr.state
: undefined
}
/>
</div>
</div>
Expand Down Expand Up @@ -294,11 +301,15 @@ function MergeStatusSection({
owner,
repo,
pullNumber,
prTitle,
firstCommitMessage,
}: {
scope: GitHubQueryScope;
owner: string;
repo: string;
pullNumber: number;
prTitle: string;
firstCommitMessage?: string;
}) {
const statusQuery = useQuery({
...githubPullStatusQueryOptions(scope, { owner, repo, pullNumber }),
Expand All @@ -314,6 +325,8 @@ function MergeStatusSection({
owner={owner}
repo={repo}
pullNumber={pullNumber}
prTitle={prTitle}
firstCommitMessage={firstCommitMessage}
/>
);
}
Expand Down Expand Up @@ -408,11 +421,15 @@ function MergeStatusCard({
owner,
repo,
pullNumber,
prTitle,
firstCommitMessage,
}: {
status: PullStatus;
owner: string;
repo: string;
pullNumber: number;
prTitle: string;
firstCommitMessage?: string;
}) {
const {
checks,
Expand Down Expand Up @@ -444,12 +461,7 @@ function MergeStatusCard({
const bypass = useMergeBypass({
isMergeBlocked,
canBypassProtections,
hasCheckFailures,
hasReviewIssue,
hasConflicts,
isBehind,
allChecksPassed,
totalChecks: checks.total,
});

return (
Expand Down Expand Up @@ -508,6 +520,8 @@ function MergeStatusCard({
owner={owner}
repo={repo}
pullNumber={pullNumber}
prTitle={prTitle}
firstCommitMessage={firstCommitMessage}
/>
</div>
);
Expand Down Expand Up @@ -585,6 +599,11 @@ function ReviewsSection({
</button>
</CollapsibleTrigger>
<CollapsibleContent>
{allReviews.length === 0 && (
<div className="border-b border-border/50 bg-surface-1/50 px-4 py-3 pl-11">
<p className="text-xs text-muted-foreground">No reviewers added</p>
</div>
)}
{allReviews.length > 0 && (
<div className="flex flex-col border-b border-border/50 bg-surface-1/50 py-1">
{allReviews.map((review) => (
Expand Down Expand Up @@ -1009,24 +1028,46 @@ function MergeFooter({
owner,
repo,
pullNumber,
prTitle,
firstCommitMessage,
}: {
isMergeBlocked: boolean;
canMerge: boolean;
bypass: ReturnType<typeof useMergeBypass>;
owner: string;
repo: string;
pullNumber: number;
prTitle: string;
firstCommitMessage?: string;
}) {
const [mergeMethod, setMergeMethod] = useState<"merge" | "squash" | "rebase">(
"squash",
);
const [isMerging, setIsMerging] = useState(false);
const [showCommitForm, setShowCommitForm] = useState(false);
const [commitTitle, setCommitTitle] = useState("");
const [commitDescription, setCommitDescription] = useState("");
const queryClient = useQueryClient();

const currentStrategy =
MERGE_STRATEGIES.find((s) => s.value === mergeMethod) ??
MERGE_STRATEGIES[0];

const getDefaultCommitTitle = useCallback(() => {
if (mergeMethod === "squash") {
return (
firstCommitMessage?.split("\n")[0] ?? `${prTitle} (#${pullNumber})`
);
}
return `Merge pull request #${pullNumber}`;
}, [mergeMethod, firstCommitMessage, prTitle, pullNumber]);

const openCommitForm = () => {
setCommitTitle(getDefaultCommitTitle());
setCommitDescription("");
setShowCommitForm(true);
};

const handleMerge = async () => {
setIsMerging(true);
try {
Expand All @@ -1037,6 +1078,8 @@ function MergeFooter({
pullNumber,
mergeMethod,
bypassProtections: bypass.shouldBypass,
commitTitle: commitTitle || undefined,
commitMessage: commitDescription || undefined,
},
});
if (result.ok) {
Expand All @@ -1055,6 +1098,65 @@ function MergeFooter({
const isDisabled =
!canMerge || (isMergeBlocked && !bypass.shouldBypass) || isMerging;

if (!canMerge) return null;

if (showCommitForm) {
return (
<div className="flex flex-col gap-3 bg-surface-1/50 px-4 py-3">
<div className="flex flex-col gap-1.5">
<label htmlFor="merge-commit-title" className="text-xs font-medium">
Commit message
</label>
<input
id="merge-commit-title"
value={commitTitle}
onChange={(e) => setCommitTitle(e.target.value)}
className="flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="merge-commit-desc" className="text-xs font-medium">
Extended description
</label>
<textarea
id="merge-commit-desc"
value={commitDescription}
onChange={(e) => setCommitDescription(e.target.value)}
placeholder="Add an optional extended description..."
rows={3}
className="flex w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none"
/>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => {
void handleMerge();
}}
disabled={isDisabled || !commitTitle.trim()}
iconLeft={
isMerging ? (
<Spinner size={14} />
) : (
<GitMergeIcon size={14} strokeWidth={2} />
)
}
>
Confirm {currentStrategy.label.toLowerCase()}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCommitForm(false)}
disabled={isMerging}
>
Cancel
</Button>
</div>
</div>
);
}

return (
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex items-center gap-3">
Expand All @@ -1063,9 +1165,7 @@ function MergeFooter({
<Button
size="sm"
disabled={isDisabled}
onClick={() => {
void handleMerge();
}}
onClick={openCommitForm}
className="rounded-r-none"
iconLeft={
isMerging ? (
Expand Down Expand Up @@ -1109,11 +1209,7 @@ function MergeFooter({
</DropdownMenu>
</div>
</div>
{!canMerge ? (
<p className="text-xs text-muted-foreground">
You don't have permission to merge this pull request.
</p>
) : isMergeBlocked && !bypass.shouldBypass ? (
{isMergeBlocked && !bypass.shouldBypass ? (
<p className="text-xs text-muted-foreground">
Merging is blocked — all required conditions have not been met.
</p>
Expand Down
26 changes: 6 additions & 20 deletions apps/dashboard/src/components/pulls/detail/use-merge-bypass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,26 @@ import { useEffect, useState } from "react";
export function useMergeBypass({
isMergeBlocked,
canBypassProtections,
hasCheckFailures,
hasReviewIssue,
hasConflicts,
isBehind,
allChecksPassed,
totalChecks,
}: {
isMergeBlocked: boolean;
canBypassProtections: boolean;
hasCheckFailures: boolean;
hasReviewIssue: boolean;
hasConflicts: boolean;
isBehind: boolean;
allChecksPassed: boolean;
totalChecks: number;
}) {
const [checked, setChecked] = useState(false);

useEffect(() => {
if (!isMergeBlocked) setChecked(false);
}, [isMergeBlocked]);

const allCriteriaMet =
!hasCheckFailures &&
!hasReviewIssue &&
!hasConflicts &&
!isBehind &&
(totalChecks === 0 || allChecksPassed);

const auto = canBypassProtections && isMergeBlocked && allCriteriaMet;
// Bypass is only relevant when merge is blocked by branch protection rules,
// not by conflicts — GitHub will always reject merging conflicting branches
// regardless of bypass permissions.
const canBypass = isMergeBlocked && canBypassProtections && !hasConflicts;

return {
shouldBypass: auto || checked,
showOption: isMergeBlocked && canBypassProtections && !allCriteriaMet,
shouldBypass: canBypass && checked,
showOption: canBypass,
checked,
setChecked,
};
Expand Down
Loading
Loading