feat(FN-5352): merge fusion/fn-5352
This commit is contained in:
@@ -27,6 +27,7 @@ import { CapacityRiskBanner } from "./components/CapacityRiskBanner";
|
||||
import { TaskIdIntegrityBanner } from "./components/TaskIdIntegrityBanner";
|
||||
import { DbCorruptionBanner } from "./components/DbCorruptionBanner";
|
||||
import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner";
|
||||
import MergeAdvanceNotice from "./components/MergeAdvanceNotice";
|
||||
import { ApprovalNotificationBanner } from "./components/ApprovalNotificationBanner";
|
||||
import { OnboardingResumeCard } from "./components/OnboardingResumeCard";
|
||||
import { PostOnboardingRecommendations } from "./components/PostOnboardingRecommendations";
|
||||
@@ -1805,6 +1806,9 @@ function AppInner() {
|
||||
onDismiss={dismissUpdateBanner}
|
||||
/>
|
||||
)}
|
||||
{viewMode === "project" && currentProject && (
|
||||
<MergeAdvanceNotice projectId={currentProject.id} />
|
||||
)}
|
||||
{viewMode === "project" && currentProject && dashboardHealth?.taskIdIntegrity?.status === "anomaly" && dashboardHealth.taskIdIntegrity.recommendedAction && (
|
||||
<TaskIdIntegrityBanner
|
||||
report={dashboardHealth.taskIdIntegrity}
|
||||
|
||||
64
packages/dashboard/app/components/MergeAdvanceNotice.css
Normal file
64
packages/dashboard/app/components/MergeAdvanceNotice.css
Normal file
@@ -0,0 +1,64 @@
|
||||
.merge-advance-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
border-inline-start: var(--space-xs) solid var(--color-warning);
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.merge-advance-notice__content {
|
||||
color: var(--text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.merge-advance-notice__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.merge-advance-notice__dismiss {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 0;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.merge-advance-notice__dismiss:hover {
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--color-warning) 18%, transparent);
|
||||
}
|
||||
|
||||
.merge-advance-notice__dismiss:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.merge-advance-notice__error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.merge-advance-notice__hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.merge-advance-notice {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.merge-advance-notice__actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
173
packages/dashboard/app/components/MergeAdvanceNotice.tsx
Normal file
173
packages/dashboard/app/components/MergeAdvanceNotice.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { api, ApiRequestError } from "../api";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import "./MergeAdvanceNotice.css";
|
||||
|
||||
interface MergeAdvanceEvent {
|
||||
taskId: string;
|
||||
integrationBranch: string;
|
||||
refName: string;
|
||||
toSha: string;
|
||||
fromSha: string | null;
|
||||
advanceMode: "fast-forward" | "non-fast-forward" | "update-ref" | string;
|
||||
succeeded: boolean;
|
||||
advancedAt: string;
|
||||
userCheckout: {
|
||||
worktreePath: string;
|
||||
dirty: boolean;
|
||||
untrackedCount: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface MergeAdvanceEventsResponse {
|
||||
events: MergeAdvanceEvent[];
|
||||
}
|
||||
|
||||
interface PullResponse {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface MergeAdvanceNoticeProps {
|
||||
projectId?: string;
|
||||
apiBase?: string;
|
||||
}
|
||||
|
||||
function shortSha(sha: string): string {
|
||||
return sha.length > 7 ? sha.slice(0, 7) : sha;
|
||||
}
|
||||
|
||||
function dismissedStorageKey(projectId?: string): string {
|
||||
return `kb:merge-advance-notice-dismissed:${projectId ?? "default"}`;
|
||||
}
|
||||
|
||||
function readDismissedShas(projectId?: string): string[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(dismissedStorageKey(projectId));
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.filter((value): value is string => typeof value === "string");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function persistDismissedShas(projectId: string | undefined, values: string[]): void {
|
||||
try {
|
||||
window.localStorage.setItem(dismissedStorageKey(projectId), JSON.stringify(values.slice(-50)));
|
||||
} catch {
|
||||
// ignore storage failures
|
||||
}
|
||||
}
|
||||
|
||||
export default function MergeAdvanceNotice({ projectId, apiBase = "/api" }: MergeAdvanceNoticeProps) {
|
||||
const [events, setEvents] = useState<MergeAdvanceEvent[]>([]);
|
||||
const [dismissedShas, setDismissedShas] = useState<string[]>(() => readDismissedShas(projectId));
|
||||
const [pulling, setPulling] = useState(false);
|
||||
const [pullError, setPullError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDismissedShas(readDismissedShas(projectId));
|
||||
}, [projectId]);
|
||||
|
||||
const fetchEvents = useCallback(async () => {
|
||||
try {
|
||||
const query = new URLSearchParams({ limit: "5" });
|
||||
if (projectId) {
|
||||
query.set("projectId", projectId);
|
||||
}
|
||||
const response = await api<MergeAdvanceEventsResponse>(`/tasks/merge-advance-events?${query.toString()}`);
|
||||
setEvents(Array.isArray(response.events) ? response.events : []);
|
||||
} catch {
|
||||
setEvents([]);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchEvents();
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const unsubscribe = subscribeSse(`${apiBase}/events${query}`, {
|
||||
events: {
|
||||
"task:merged": () => {
|
||||
void fetchEvents();
|
||||
},
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [apiBase, fetchEvents, projectId]);
|
||||
|
||||
const notice = useMemo(() => events.find((event) => (
|
||||
event.succeeded === true
|
||||
&& event.userCheckout !== null
|
||||
&& event.userCheckout.worktreePath.trim().length > 0
|
||||
)), [events]);
|
||||
|
||||
if (!notice || dismissedShas.includes(notice.toSha)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const localChangesPreserved = notice.userCheckout.dirty || notice.userCheckout.untrackedCount > 0;
|
||||
|
||||
const dismiss = () => {
|
||||
const next = [...dismissedShas.filter((sha) => sha !== notice.toSha), notice.toSha].slice(-50);
|
||||
setDismissedShas(next);
|
||||
persistDismissedShas(projectId, next);
|
||||
};
|
||||
|
||||
const handlePull = async () => {
|
||||
setPulling(true);
|
||||
setPullError(null);
|
||||
try {
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
await api<PullResponse>(`/git/pull${query}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rebase: false }),
|
||||
});
|
||||
dismiss();
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ApiRequestError) {
|
||||
setPullError(error.message || "Pull failed");
|
||||
} else if (error instanceof Error && error.message) {
|
||||
setPullError(error.message);
|
||||
} else {
|
||||
setPullError("Pull failed");
|
||||
}
|
||||
} finally {
|
||||
setPulling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="merge-advance-notice" role="status" aria-live="polite">
|
||||
<div className="merge-advance-notice__content">
|
||||
<strong>{notice.integrationBranch} advanced to {shortSha(notice.toSha)}.</strong>{" "}
|
||||
Your checked-out copy at {notice.userCheckout.worktreePath} is behind.
|
||||
{localChangesPreserved ? " (local changes preserved)" : ""}
|
||||
{pullError ? <span className="merge-advance-notice__error"> {pullError}</span> : null}
|
||||
{pulling ? <span className="merge-advance-notice__hint"> Pulling…</span> : null}
|
||||
</div>
|
||||
<div className="merge-advance-notice__actions">
|
||||
{!localChangesPreserved ? (
|
||||
<button type="button" className="btn btn-sm" disabled={pulling} onClick={handlePull}>
|
||||
Pull
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="merge-advance-notice__dismiss touch-target"
|
||||
aria-label="Dismiss merge advance notice"
|
||||
onClick={dismiss}
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import MergeAdvanceNotice from "../MergeAdvanceNotice";
|
||||
import { ApiRequestError } from "../../api";
|
||||
|
||||
const mocked = vi.hoisted(() => ({
|
||||
api: vi.fn(),
|
||||
mergedHandler: undefined as (() => void) | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../../api", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../api")>("../../api");
|
||||
return {
|
||||
...actual,
|
||||
api: mocked.api,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../sse-bus", () => ({
|
||||
subscribeSse: vi.fn((_url: string, options: { events?: Record<string, () => void> }) => {
|
||||
mocked.mergedHandler = options.events?.["task:merged"];
|
||||
return vi.fn();
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeEvent(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
taskId: "FN-1",
|
||||
integrationBranch: "master",
|
||||
refName: "refs/heads/master",
|
||||
toSha: "abcdef123456",
|
||||
fromSha: "1234567",
|
||||
advanceMode: "update-ref",
|
||||
succeeded: true,
|
||||
advancedAt: "2026-05-21T12:00:00.000Z",
|
||||
userCheckout: {
|
||||
worktreePath: "/repo",
|
||||
dirty: false,
|
||||
untrackedCount: 0,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MergeAdvanceNotice", () => {
|
||||
beforeEach(() => {
|
||||
mocked.api.mockReset();
|
||||
mocked.mergedHandler = undefined;
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders nothing when api returns no events", async () => {
|
||||
mocked.api.mockResolvedValueOnce({ events: [] });
|
||||
const { container } = render(<MergeAdvanceNotice projectId="proj-1" />);
|
||||
await waitFor(() => expect(mocked.api).toHaveBeenCalledTimes(1));
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders notice with dynamic branch name", async () => {
|
||||
mocked.api.mockResolvedValueOnce({ events: [makeEvent()] });
|
||||
render(<MergeAdvanceNotice projectId="proj-1" />);
|
||||
expect(await screen.findByText(/master advanced to abcdef1\./)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Your checked-out copy at \/repo is behind\./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ dirty: true, untrackedCount: 0 },
|
||||
{ dirty: false, untrackedCount: 2 },
|
||||
])("shows local changes preserved and hides pull when dirty markers present", async ({ dirty, untrackedCount }) => {
|
||||
mocked.api.mockResolvedValueOnce({ events: [makeEvent({ userCheckout: { worktreePath: "/repo", dirty, untrackedCount } })] });
|
||||
render(<MergeAdvanceNotice projectId="proj-1" />);
|
||||
expect(await screen.findByText(/local changes preserved/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Pull" })).toBeNull();
|
||||
});
|
||||
|
||||
it("pull posts rebase false and dismisses on success", async () => {
|
||||
mocked.api
|
||||
.mockResolvedValueOnce({ events: [makeEvent()] })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
render(<MergeAdvanceNotice projectId="proj-1" />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Pull" }));
|
||||
|
||||
await waitFor(() => expect(mocked.api).toHaveBeenNthCalledWith(2, "/git/pull?projectId=proj-1", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rebase: false }),
|
||||
}));
|
||||
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
|
||||
});
|
||||
|
||||
it("shows inline pull failure and keeps notice visible", async () => {
|
||||
mocked.api
|
||||
.mockResolvedValueOnce({ events: [makeEvent()] })
|
||||
.mockRejectedValueOnce(new ApiRequestError("Merge conflict detected", 409));
|
||||
render(<MergeAdvanceNotice projectId="proj-1" />);
|
||||
|
||||
const pullButton = await screen.findByRole("button", { name: "Pull" });
|
||||
fireEvent.click(pullButton);
|
||||
|
||||
expect(await screen.findByText(/Merge conflict detected/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Pull" })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("dismisses current sha and stays hidden when same advance is refetched", async () => {
|
||||
mocked.api
|
||||
.mockResolvedValueOnce({ events: [makeEvent()] })
|
||||
.mockResolvedValueOnce({ events: [makeEvent()] });
|
||||
render(<MergeAdvanceNotice projectId="proj-1" />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /dismiss merge advance notice/i }));
|
||||
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
|
||||
|
||||
mocked.mergedHandler?.();
|
||||
await waitFor(() => expect(mocked.api).toHaveBeenCalledTimes(2));
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows fresh advance after previous sha dismissed", async () => {
|
||||
mocked.api
|
||||
.mockResolvedValueOnce({ events: [makeEvent({ toSha: "aaaaaaa111" })] })
|
||||
.mockResolvedValueOnce({ events: [makeEvent({ toSha: "bbbbbbb222" })] });
|
||||
render(<MergeAdvanceNotice projectId="proj-1" />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /dismiss merge advance notice/i }));
|
||||
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
|
||||
|
||||
mocked.mergedHandler?.();
|
||||
expect(await screen.findByText(/master advanced to bbbbbbb\./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing for failed advance or null checkout", async () => {
|
||||
mocked.api.mockResolvedValueOnce({ events: [makeEvent({ succeeded: false }), makeEvent({ userCheckout: null })] });
|
||||
const { container } = render(<MergeAdvanceNotice projectId="proj-1" />);
|
||||
await waitFor(() => expect(mocked.api).toHaveBeenCalledTimes(1));
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import express from "express";
|
||||
import type { TaskStore, RunAuditEvent } from "@fusion/core";
|
||||
import { createApiRoutes } from "../../routes.js";
|
||||
import { request as REQUEST } from "../../test-request.js";
|
||||
|
||||
function makeEvent(overrides: Partial<RunAuditEvent>): RunAuditEvent {
|
||||
return {
|
||||
id: "evt-1",
|
||||
timestamp: "2026-05-21T00:00:00.000Z",
|
||||
taskId: "FN-100",
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
domain: "git",
|
||||
mutationType: "merge:integration-ref-advance",
|
||||
target: "merge",
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("merge advance events route", () => {
|
||||
it("returns empty events when audit store is empty", async () => {
|
||||
const store: TaskStore = {
|
||||
getRootDir: vi.fn(() => process.cwd()),
|
||||
getRunAuditEvents: vi.fn(() => []),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
const app = express();
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
const res = await REQUEST(app, "GET", "/api/tasks/merge-advance-events");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ events: [] });
|
||||
});
|
||||
|
||||
it("hydrates matching worktree state and preserves dynamic branch names", async () => {
|
||||
const advance = makeEvent({
|
||||
id: "evt-advance",
|
||||
mutationType: "merge:integration-ref-advance",
|
||||
timestamp: "2026-05-21T10:00:00.000Z",
|
||||
metadata: {
|
||||
integrationBranch: "master",
|
||||
refName: "refs/heads/master",
|
||||
fromSha: "abc1234",
|
||||
toSha: "def5678",
|
||||
advanceMode: "update-ref",
|
||||
succeeded: true,
|
||||
},
|
||||
});
|
||||
const state = makeEvent({
|
||||
id: "evt-state",
|
||||
mutationType: "merge:integration-worktree-state",
|
||||
timestamp: "2026-05-21T09:59:59.000Z",
|
||||
metadata: {
|
||||
userCheckout: {
|
||||
worktreePath: "/repo",
|
||||
dirty: true,
|
||||
untrackedCount: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const getRunAuditEvents = vi.fn((filters?: { mutationType?: string }) => {
|
||||
if (filters?.mutationType === "merge:integration-ref-advance") {
|
||||
return [advance];
|
||||
}
|
||||
if (filters?.mutationType === "merge:integration-worktree-state") {
|
||||
return [state];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const store: TaskStore = {
|
||||
getRootDir: vi.fn(() => process.cwd()),
|
||||
getRunAuditEvents,
|
||||
} as unknown as TaskStore;
|
||||
|
||||
const app = express();
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
const res = await REQUEST(app, "GET", "/api/tasks/merge-advance-events");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
events: [
|
||||
{
|
||||
taskId: "FN-100",
|
||||
integrationBranch: "master",
|
||||
refName: "refs/heads/master",
|
||||
toSha: "def5678",
|
||||
fromSha: "abc1234",
|
||||
advanceMode: "update-ref",
|
||||
succeeded: true,
|
||||
advancedAt: "2026-05-21T10:00:00.000Z",
|
||||
userCheckout: {
|
||||
worktreePath: "/repo",
|
||||
dirty: true,
|
||||
untrackedCount: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps succeeded false from metadata", async () => {
|
||||
const advance = makeEvent({
|
||||
id: "evt-advance-fail",
|
||||
metadata: {
|
||||
integrationBranch: "trunk",
|
||||
refName: "refs/heads/trunk",
|
||||
toSha: "deadbeef",
|
||||
advanceMode: "update-ref",
|
||||
succeeded: false,
|
||||
},
|
||||
});
|
||||
|
||||
const store: TaskStore = {
|
||||
getRootDir: vi.fn(() => process.cwd()),
|
||||
getRunAuditEvents: vi.fn((filters?: { mutationType?: string }) =>
|
||||
filters?.mutationType === "merge:integration-ref-advance" ? [advance] : []),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
const app = express();
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
const res = await REQUEST(app, "GET", "/api/tasks/merge-advance-events");
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as { events: Array<{ succeeded: boolean }> }).events[0]?.succeeded).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults limit to 20, clamps max to 100, rejects invalid limit", async () => {
|
||||
const store: TaskStore = {
|
||||
getRootDir: vi.fn(() => process.cwd()),
|
||||
getRunAuditEvents: vi.fn(() => []),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
const app = express();
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
const defaultRes = await REQUEST(app, "GET", "/api/tasks/merge-advance-events");
|
||||
expect(defaultRes.status).toBe(200);
|
||||
|
||||
const maxRes = await REQUEST(app, "GET", "/api/tasks/merge-advance-events?limit=999");
|
||||
expect(maxRes.status).toBe(200);
|
||||
|
||||
const badRes = await REQUEST(app, "GET", "/api/tasks/merge-advance-events?limit=abc");
|
||||
expect(badRes.status).toBe(400);
|
||||
|
||||
const getRunAuditEvents = store.getRunAuditEvents as unknown as ReturnType<typeof vi.fn>;
|
||||
const firstAdvanceCall = getRunAuditEvents.mock.calls.find((call) => call[0]?.mutationType === "merge:integration-ref-advance");
|
||||
const secondAdvanceCall = [...getRunAuditEvents.mock.calls].reverse().find((call) => call[0]?.mutationType === "merge:integration-ref-advance");
|
||||
expect(firstAdvanceCall?.[0]?.limit).toBe(20);
|
||||
expect(secondAdvanceCall?.[0]?.limit).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
GithubIssueAction,
|
||||
DuplicateCandidate,
|
||||
DuplicateMatch,
|
||||
RunAuditEvent,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
COLUMNS,
|
||||
@@ -49,6 +50,90 @@ const REVIEW_VERDICT_RE = /###\s+Verdict:\s*(APPROVE|REVISE|RETHINK|UNAVAILABLE)
|
||||
const REVIEW_STEP_RE = /^(plan|code) review Step (\d+): (APPROVE|REVISE|RETHINK|UNAVAILABLE)\b/i;
|
||||
const DUPLICATE_STOPWORDS = new Set(["a", "an", "the", "and", "or", "of", "to", "for", "in", "is", "on", "with", "fn"]);
|
||||
|
||||
interface MergeAdvanceEvent {
|
||||
taskId: string;
|
||||
integrationBranch: string;
|
||||
refName: string;
|
||||
toSha: string;
|
||||
fromSha: string | null;
|
||||
advanceMode: "fast-forward" | "non-fast-forward" | "update-ref" | string;
|
||||
succeeded: boolean;
|
||||
advancedAt: string;
|
||||
userCheckout: {
|
||||
worktreePath: string;
|
||||
dirty: boolean;
|
||||
untrackedCount: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface MergeAdvanceEventsResponse {
|
||||
events: MergeAdvanceEvent[];
|
||||
}
|
||||
|
||||
function parseMergeAdvanceLimit(rawLimit: unknown): number {
|
||||
if (rawLimit === undefined) {
|
||||
return 20;
|
||||
}
|
||||
const parsed = Number.parseInt(String(rawLimit), 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw badRequest("limit must be a positive integer");
|
||||
}
|
||||
return Math.min(parsed, 100);
|
||||
}
|
||||
|
||||
function extractUserCheckout(metadata: unknown): MergeAdvanceEvent["userCheckout"] {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return null;
|
||||
}
|
||||
const raw = (metadata as { userCheckout?: unknown }).userCheckout;
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
const candidate = raw as { worktreePath?: unknown; dirty?: unknown; untrackedCount?: unknown };
|
||||
if (typeof candidate.worktreePath !== "string" || candidate.worktreePath.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
worktreePath: candidate.worktreePath,
|
||||
dirty: candidate.dirty === true,
|
||||
untrackedCount: typeof candidate.untrackedCount === "number" ? candidate.untrackedCount : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function extractMergeAdvanceEvent(event: RunAuditEvent): Omit<MergeAdvanceEvent, "userCheckout"> | null {
|
||||
const metadata = event.metadata;
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
console.warn(`[merge-advance-events] dropping run-audit event ${event.id}: missing metadata`);
|
||||
return null;
|
||||
}
|
||||
const candidate = metadata as {
|
||||
integrationBranch?: unknown;
|
||||
refName?: unknown;
|
||||
toSha?: unknown;
|
||||
fromSha?: unknown;
|
||||
advanceMode?: unknown;
|
||||
succeeded?: unknown;
|
||||
};
|
||||
if (typeof candidate.integrationBranch !== "string" || candidate.integrationBranch.length === 0 || typeof candidate.toSha !== "string" || candidate.toSha.length === 0) {
|
||||
console.warn(`[merge-advance-events] dropping run-audit event ${event.id}: missing integrationBranch or toSha`);
|
||||
return null;
|
||||
}
|
||||
if (typeof event.taskId !== "string" || event.taskId.length === 0) {
|
||||
console.warn(`[merge-advance-events] dropping run-audit event ${event.id}: missing taskId`);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
taskId: event.taskId,
|
||||
integrationBranch: candidate.integrationBranch,
|
||||
refName: typeof candidate.refName === "string" ? candidate.refName : `refs/heads/${candidate.integrationBranch}`,
|
||||
toSha: candidate.toSha,
|
||||
fromSha: typeof candidate.fromSha === "string" ? candidate.fromSha : null,
|
||||
advanceMode: typeof candidate.advanceMode === "string" ? candidate.advanceMode : "update-ref",
|
||||
succeeded: candidate.succeeded !== false,
|
||||
advancedAt: event.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
export const __fingerprintCreateLocksForTests = deterministicGuardLocks;
|
||||
|
||||
const RESET_TASK_FIELDS = {
|
||||
@@ -274,7 +359,66 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
} = deps;
|
||||
const TASK_DETAIL_ACTIVITY_LOG_LIMIT = taskDetailActivityLogLimit;
|
||||
|
||||
// List all tasks
|
||||
// Get recent integration-branch advance events for post-merge notice
|
||||
router.get("/tasks/merge-advance-events", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const limit = parseMergeAdvanceLimit(req.query.limit);
|
||||
const getRunAuditEvents = (scopedStore as TaskStore & {
|
||||
getRunAuditEvents?: (filters: {
|
||||
taskId?: string;
|
||||
domain?: "database" | "git" | "filesystem" | "sandbox";
|
||||
mutationType?: string;
|
||||
limit?: number;
|
||||
}) => RunAuditEvent[];
|
||||
}).getRunAuditEvents;
|
||||
if (typeof getRunAuditEvents !== "function") {
|
||||
throw notFound("run-audit unavailable");
|
||||
}
|
||||
|
||||
const advanceEvents = getRunAuditEvents({
|
||||
domain: "git",
|
||||
mutationType: "merge:integration-ref-advance",
|
||||
limit,
|
||||
});
|
||||
|
||||
const events: MergeAdvanceEvent[] = [];
|
||||
for (const advanceEvent of advanceEvents) {
|
||||
const extracted = extractMergeAdvanceEvent(advanceEvent);
|
||||
if (!extracted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let userCheckout: MergeAdvanceEvent["userCheckout"] = null;
|
||||
const stateEvents = getRunAuditEvents({
|
||||
taskId: extracted.taskId,
|
||||
domain: "git",
|
||||
mutationType: "merge:integration-worktree-state",
|
||||
limit,
|
||||
});
|
||||
const matchingState = stateEvents.find((stateEvent) => stateEvent.timestamp <= advanceEvent.timestamp);
|
||||
if (matchingState) {
|
||||
userCheckout = extractUserCheckout(matchingState.metadata);
|
||||
}
|
||||
|
||||
events.push({
|
||||
...extracted,
|
||||
userCheckout,
|
||||
});
|
||||
}
|
||||
|
||||
const response: MergeAdvanceEventsResponse = {
|
||||
events: events.sort((a, b) => Date.parse(b.advancedAt) - Date.parse(a.advancedAt)),
|
||||
};
|
||||
res.json(response);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/tasks", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
|
||||
@@ -12,7 +12,7 @@ const qualityAppTests = [
|
||||
"app/api/**/*.test.ts",
|
||||
// Representative workflow/component coverage. Exhaustive modal/view suites
|
||||
// stay available in the full `dashboard-app` project.
|
||||
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
|
||||
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
|
||||
// Hooks and utilities are fast, user-visible state/formatting behavior.
|
||||
"app/context/**/*.test.tsx",
|
||||
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",
|
||||
|
||||
Reference in New Issue
Block a user