feat(web): merge conflict compare link and copy branch on session PR card (#1318)
* feat(web): merge conflict compare link and branch copy on PR card - Add buildGitHubCompareUrl helper with unit tests - Show compare + copy head branch when open PR has merge conflicts - Add SessionDetail regression test for conflict affordances Made-with: Cursor * fix(web): harden merge-conflict PR actions Address review feedback by correcting the changeset package name, encoding all compare URL path segments, and making conflict actions resilient to unenriched/rate-limited PR data and clipboard/timer edge cases. Add regression tests for URL encoding and conflict-action gating. Made-with: Cursor * fix(web): normalize browser timer handles in session detail Use browser timer handle types consistently in the session detail PR card copy-feedback flow so Next.js typechecking does not mix Node Timeout and DOM number timer signatures. Made-with: Cursor
This commit is contained in:
parent
64badbd388
commit
fed25d5d29
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@aoagents/ao-web": patch
|
||||
---
|
||||
|
||||
Show GitHub compare and copy-branch actions on session PR detail when the PR has merge conflicts.
|
||||
|
|
@ -3004,6 +3004,47 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active {
|
|||
min-height: 0;
|
||||
}
|
||||
|
||||
.session-detail-pr-card__merge-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px 10px;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.session-detail-pr-merge-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
padding: 6px 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-status-attention) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--color-status-attention) 8%, transparent);
|
||||
color: var(--color-status-attention);
|
||||
transition: background-color 120ms;
|
||||
}
|
||||
|
||||
.session-detail-pr-merge-action:hover {
|
||||
background: color-mix(in srgb, var(--color-status-attention) 14%, transparent);
|
||||
}
|
||||
|
||||
.session-detail-pr-merge-action--btn {
|
||||
border-color: var(--color-border-default);
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.session-detail-pr-merge-action--btn:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.session-detail-pr-card__title-link {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
|
|
|
|||
|
|
@ -9,11 +9,14 @@ import {
|
|||
TERMINAL_STATUSES,
|
||||
NON_RESTORABLE_STATUSES,
|
||||
isPRMergeReady,
|
||||
isPRRateLimited,
|
||||
isPRUnenriched,
|
||||
} from "@/lib/types";
|
||||
import { CI_STATUS } from "@aoagents/ao-core/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import dynamic from "next/dynamic";
|
||||
import { getSessionTitle } from "@/lib/format";
|
||||
import { buildGitHubCompareUrl } from "@/lib/github-links";
|
||||
import type { ProjectInfo } from "@/lib/project-name";
|
||||
import { SidebarContext } from "./workspace/SidebarContext";
|
||||
|
||||
|
|
@ -724,11 +727,12 @@ function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; ses
|
|||
const [sendingComments, setSendingComments] = useState<Set<string>>(new Set());
|
||||
const [sentComments, setSentComments] = useState<Set<string>>(new Set());
|
||||
const [errorComments, setErrorComments] = useState<Set<string>>(new Set());
|
||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const [branchCopied, setBranchCopied] = useState(false);
|
||||
const timersRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timersRef.current.forEach((timer) => clearTimeout(timer));
|
||||
timersRef.current.forEach((timer) => window.clearTimeout(timer));
|
||||
timersRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
|
@ -757,8 +761,8 @@ function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; ses
|
|||
});
|
||||
setSentComments((prev) => new Set(prev).add(comment.url));
|
||||
const existing = timersRef.current.get(comment.url);
|
||||
if (existing) clearTimeout(existing);
|
||||
const timer = setTimeout(() => {
|
||||
if (existing !== undefined) window.clearTimeout(existing);
|
||||
const timer = window.setTimeout(() => {
|
||||
setSentComments((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(comment.url);
|
||||
|
|
@ -776,8 +780,8 @@ function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; ses
|
|||
});
|
||||
setErrorComments((prev) => new Set(prev).add(comment.url));
|
||||
const existing = timersRef.current.get(comment.url);
|
||||
if (existing) clearTimeout(existing);
|
||||
const timer = setTimeout(() => {
|
||||
if (existing !== undefined) window.clearTimeout(existing);
|
||||
const timer = window.setTimeout(() => {
|
||||
setErrorComments((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(comment.url);
|
||||
|
|
@ -794,6 +798,32 @@ function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; ses
|
|||
const blockerIssues = buildBlockerChips(pr, metadata);
|
||||
const fileCount = pr.changedFiles ?? 0;
|
||||
|
||||
const mergeabilityReliable = !isPRUnenriched(pr) && !isPRRateLimited(pr);
|
||||
const hasConflicts = mergeabilityReliable && pr.state !== "merged" && !pr.mergeability.noConflicts;
|
||||
const showConflictActions = hasConflicts && pr.state === "open";
|
||||
const compareUrl = showConflictActions ? buildGitHubCompareUrl(pr) : "";
|
||||
|
||||
const handleCopyBranch = () => {
|
||||
const clipboardWrite = navigator.clipboard?.writeText(pr.branch);
|
||||
if (!clipboardWrite) return;
|
||||
|
||||
void clipboardWrite
|
||||
.then(() => {
|
||||
setBranchCopied(true);
|
||||
const timerKey = "__copy-branch";
|
||||
const existing = timersRef.current.get(timerKey);
|
||||
if (existing !== undefined) window.clearTimeout(existing);
|
||||
const timer = window.setTimeout(() => {
|
||||
setBranchCopied(false);
|
||||
timersRef.current.delete(timerKey);
|
||||
}, 2000);
|
||||
timersRef.current.set(timerKey, timer);
|
||||
})
|
||||
.catch(() => {
|
||||
/* clipboard unavailable */
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("session-detail-pr-card", allGreen && "session-detail-pr-card--green")}>
|
||||
{/* Row 1: Title + diff stats */}
|
||||
|
|
@ -823,6 +853,31 @@ function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; ses
|
|||
)}
|
||||
</div>
|
||||
|
||||
{showConflictActions ? (
|
||||
<div
|
||||
className="session-detail-pr-card__merge-actions"
|
||||
role="group"
|
||||
aria-label="Resolve merge conflicts"
|
||||
>
|
||||
<a
|
||||
href={compareUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="session-detail-pr-merge-action"
|
||||
>
|
||||
Compare with base branch
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyBranch}
|
||||
aria-label={branchCopied ? "Head branch name copied" : "Copy head branch name"}
|
||||
className="session-detail-pr-merge-action session-detail-pr-merge-action--btn"
|
||||
>
|
||||
{branchCopied ? "Copied branch name" : "Copy head branch name"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Row 2: Blocker chips + CI chips inline */}
|
||||
<div className="session-detail-pr-card__details">
|
||||
{allGreen ? (
|
||||
|
|
@ -995,7 +1050,8 @@ function buildBlockerChips(pr: DashboardPR, metadata: Record<string, string>): B
|
|||
const ciIsFailing = pr.ciStatus === CI_STATUS.FAILING || lifecycleStatus === "ci_failed";
|
||||
const hasChangesRequested =
|
||||
pr.reviewDecision === "changes_requested" || lifecycleStatus === "changes_requested";
|
||||
const hasConflicts = pr.state !== "merged" && !pr.mergeability.noConflicts;
|
||||
const mergeabilityReliable = !isPRUnenriched(pr) && !isPRRateLimited(pr);
|
||||
const hasConflicts = mergeabilityReliable && pr.state !== "merged" && !pr.mergeability.noConflicts;
|
||||
|
||||
if (ciIsFailing) {
|
||||
const failCount = pr.ciChecks.filter((c) => c.status === "failed").length;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { SessionDetail } from "../SessionDetail";
|
||||
import { makePR, makeSession } from "../../__tests__/helpers";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("../DirectTerminal", () => ({
|
||||
DirectTerminal: ({ sessionId }: { sessionId: string }) => (
|
||||
<div data-testid="direct-terminal">{sessionId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
function mockDesktopViewport() {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: !query.includes("max-width: 767px"),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("SessionDetail merge conflict actions", () => {
|
||||
beforeEach(() => {
|
||||
mockDesktopViewport();
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(""),
|
||||
} as Response),
|
||||
);
|
||||
Object.defineProperty(globalThis.navigator, "clipboard", {
|
||||
value: { writeText: vi.fn(() => Promise.resolve()) },
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders compare and copy actions when the PR has merge conflicts", () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
id: "worker-conflict",
|
||||
projectId: "my-app",
|
||||
pr: makePR({
|
||||
number: 99,
|
||||
title: "Conflict PR",
|
||||
branch: "feat/has-conflict",
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: false,
|
||||
blockers: ["Merge conflicts"],
|
||||
},
|
||||
}),
|
||||
})}
|
||||
projectOrchestratorId="orch-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "PR #99" }));
|
||||
|
||||
const compare = screen.getByRole("link", { name: /Compare with base branch/i });
|
||||
expect(compare).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/acme/app/compare/main...feat%2Fhas-conflict",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Copy head branch name/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides conflict actions when mergeability data is not reliable", () => {
|
||||
render(
|
||||
<SessionDetail
|
||||
session={makeSession({
|
||||
id: "worker-unenriched",
|
||||
projectId: "my-app",
|
||||
pr: makePR({
|
||||
number: 100,
|
||||
enriched: false,
|
||||
mergeability: {
|
||||
mergeable: false,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: false,
|
||||
blockers: ["API rate limited or unavailable"],
|
||||
},
|
||||
}),
|
||||
})}
|
||||
projectOrchestratorId="orch-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "PR #100" }));
|
||||
expect(screen.queryByRole("link", { name: /Compare with base branch/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Copy head branch name/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildGitHubCompareUrl } from "../github-links";
|
||||
|
||||
describe("buildGitHubCompareUrl", () => {
|
||||
it("builds a GitHub compare URL for base and head branches", () => {
|
||||
expect(
|
||||
buildGitHubCompareUrl({
|
||||
owner: "acme",
|
||||
repo: "app",
|
||||
baseBranch: "main",
|
||||
branch: "feat/foo-bar",
|
||||
}),
|
||||
).toBe("https://github.com/acme/app/compare/main...feat%2Ffoo-bar");
|
||||
});
|
||||
|
||||
it("encodes special characters in branch names", () => {
|
||||
expect(
|
||||
buildGitHubCompareUrl({
|
||||
owner: "o",
|
||||
repo: "r",
|
||||
baseBranch: "release/1.0",
|
||||
branch: "fix#123",
|
||||
}),
|
||||
).toBe("https://github.com/o/r/compare/release%2F1.0...fix%23123");
|
||||
});
|
||||
|
||||
it("encodes owner and repo segments", () => {
|
||||
expect(
|
||||
buildGitHubCompareUrl({
|
||||
owner: "../../evil",
|
||||
repo: "app?tab=code",
|
||||
baseBranch: "main",
|
||||
branch: "feat/x",
|
||||
}),
|
||||
).toBe("https://github.com/..%2F..%2Fevil/app%3Ftab%3Dcode/compare/main...feat%2Fx");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import type { DashboardPR } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* GitHub compare URL for the PR head branch against its base branch.
|
||||
* Used when resolving merge conflicts (GitHub compare view).
|
||||
*/
|
||||
export function buildGitHubCompareUrl(
|
||||
pr: Pick<DashboardPR, "owner" | "repo" | "baseBranch" | "branch">,
|
||||
): string {
|
||||
const owner = encodeURIComponent(pr.owner);
|
||||
const repo = encodeURIComponent(pr.repo);
|
||||
const base = encodeURIComponent(pr.baseBranch);
|
||||
const head = encodeURIComponent(pr.branch);
|
||||
return `https://github.com/${owner}/${repo}/compare/${base}...${head}`;
|
||||
}
|
||||
Loading…
Reference in New Issue