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
2 changes: 2 additions & 0 deletions packages/ui/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,15 @@ export {
deleteSharingDomainProjectMapping,
enrollPeer,
importCoordinatorInvite,
LegacySharedReviewConfirmationError,
loadCoordinatorGroupPreferences,
loadPairing,
loadProjectScopeInventory,
loadSharingDomainSettings,
loadSyncActors,
loadSyncStatus,
mergeActor,
reassignLegacySharedReviewGroup,
reassignProjectInventoryProject,
renameActor,
renamePeer,
Expand Down
55 changes: 55 additions & 0 deletions packages/ui/src/lib/api/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,25 @@ export interface ProjectReassignmentResult {
moved_memory_count: number;
}

export interface LegacySharedReviewReassignmentPreview {
workspace_identity: string;
scope_id: string;
target_scope_label: string;
memory_count: number;
reassignable_memory_count: number;
skipped_memory_count: number;
affected_peer_device_count: number;
affected_peer_device_ids: string[];
warning: string;
}

export interface LegacySharedReviewReassignmentResult
extends LegacySharedReviewReassignmentPreview {
ok?: boolean;
reassigned_memory_count: number;
legacy_shared_review?: Record<string, unknown> | null;
}

export interface SharingDomainSettings {
scopes: SharingDomainScope[];
mappings: ProjectScopeMapping[];
Expand All @@ -270,6 +289,16 @@ export class SharingDomainGuardrailConfirmationError extends Error {
}
}

export class LegacySharedReviewConfirmationError extends Error {
preview: LegacySharedReviewReassignmentPreview;

constructor(preview: LegacySharedReviewReassignmentPreview) {
super("Legacy shared review confirmation required");
this.name = "LegacySharedReviewConfirmationError";
this.preview = preview;
}
}

export async function loadSharingDomainSettings(): Promise<SharingDomainSettings> {
return fetchJson<SharingDomainSettings>("/api/sync/sharing-domains/settings");
}
Expand Down Expand Up @@ -353,6 +382,32 @@ export async function reassignProjectInventoryProject(input: {
return payload;
}

export async function reassignLegacySharedReviewGroup(input: {
workspace_identity: string;
scope_id: string;
confirmed_old_copies?: boolean;
}): Promise<LegacySharedReviewReassignmentResult> {
const resp = await fetch("/api/sync/legacy-shared-review/reassign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const { text, payload } = await readJsonPayload<
LegacySharedReviewReassignmentResult & {
error?: string;
preview?: LegacySharedReviewReassignmentPreview;
}
>(resp);
if (!resp.ok) {
if (payload?.error === "legacy_review_confirmation_required" && payload.preview) {
throw new LegacySharedReviewConfirmationError(payload.preview);
}
throw new Error(payloadError(payload) || text || "request failed");
}
if (!payload?.workspace_identity) throw new Error("response missing legacy review reassignment");
return payload;
}

export async function loadCoordinatorGroupPreferences(
groupId: string,
): Promise<CoordinatorGroupPreferences> {
Expand Down
75 changes: 73 additions & 2 deletions packages/ui/src/tabs/sync/components/sync-sharing-review.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,80 @@ describe("SyncSharingReview", () => {
"Remapping or revocation does not erase data already copied",
);

const button = root.querySelector("button");
expect(button?.textContent).toBe("Review projects");
const buttons = [...root.querySelectorAll("button")];
const button = buttons.find((item) => item.textContent === "Review projects");
expect(button).toBeTruthy();
button?.click();
expect(onLegacyReview).toHaveBeenCalledTimes(1);
});

it("requires explicit confirmation before applying a suggested legacy domain", async () => {
const onLegacyReassign = vi
.fn()
.mockResolvedValueOnce({
affected_peer_device_count: 1,
affected_peer_device_ids: ["peer-a"],
memory_count: 3,
reassignable_memory_count: 2,
scope_id: "oss",
skipped_memory_count: 1,
target_scope_label: "OSS",
warning:
"This changes future sync authorization but does not erase data already copied to peers.",
workspace_identity: "https://git.example.invalid/oss/dev.git",
})
.mockResolvedValueOnce(null);
const root = renderReview(
<SyncSharingReview
items={[]}
legacyReview={{
groups: [
{
displayProject: "oss-dev",
identitySource: "git_remote",
lastUpdatedAt: null,
memoryCount: 3,
suggestedScopeId: "oss",
suggestionReason: "Existing project mapping can be reviewed.",
workspaceIdentity: "https://git.example.invalid/oss/dev.git",
},
],
memoryCount: 3,
scopeId: "legacy-shared-review",
}}
onLegacyReassign={onLegacyReassign}
onReview={() => {}}
/>,
);

const applyButton = [...root.querySelectorAll("button")].find(
(button) => button.textContent === "Review suggested reassignment",
);
expect(applyButton).toBeTruthy();
await act(async () => {
applyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});

expect(onLegacyReassign).toHaveBeenCalledWith(
expect.objectContaining({ workspaceIdentity: "https://git.example.invalid/oss/dev.git" }),
"oss",
false,
);
expect(root.textContent).toContain("2 of 3 memories");
expect(root.textContent).toContain("1 peer-owned copies will be left unchanged");

const confirmButton = [...root.querySelectorAll("button")].find(
(button) => button.textContent === "I understand, reassign memories",
);
expect(confirmButton).toBeTruthy();
await act(async () => {
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});

expect(onLegacyReassign).toHaveBeenLastCalledWith(
expect.objectContaining({ workspaceIdentity: "https://git.example.invalid/oss/dev.git" }),
"oss",
true,
);
});
});
70 changes: 69 additions & 1 deletion packages/ui/src/tabs/sync/components/sync-sharing-review.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { useState } from "preact/hooks";

import type { LegacySharedReviewReassignmentPreview } from "../../../lib/api/sync";

export interface SyncSharingReviewItem {
actorDisplayName: string;
actorId: string;
Expand Down Expand Up @@ -26,6 +30,11 @@ export interface SyncLegacySharedReviewGroup {
type SyncSharingReviewProps = {
items: SyncSharingReviewItem[];
legacyReview?: SyncLegacySharedReviewItem | null;
onLegacyReassign?: (
group: SyncLegacySharedReviewGroup,
scopeId: string,
confirmedOldCopies: boolean,
) => Promise<LegacySharedReviewReassignmentPreview | null>;
onLegacyReview?: () => void;
onReview: () => void;
};
Expand Down Expand Up @@ -60,12 +69,41 @@ function SharingReviewRow({

function LegacySharedReviewRow({
item,
onReassign,
onReview,
}: {
item: SyncLegacySharedReviewItem;
onReassign?: (
group: SyncLegacySharedReviewGroup,
scopeId: string,
confirmedOldCopies: boolean,
) => Promise<LegacySharedReviewReassignmentPreview | null>;
onReview: () => void;
}) {
const groups = item.groups ?? [];
const [pending, setPending] = useState<Record<string, LegacySharedReviewReassignmentPreview>>({});
const [busyIdentity, setBusyIdentity] = useState<string | null>(null);
async function applyGroup(group: SyncLegacySharedReviewGroup) {
if (!group.suggestedScopeId || !onReassign || busyIdentity) return;
setBusyIdentity(group.workspaceIdentity);
try {
const preview = await onReassign(
group,
group.suggestedScopeId,
Boolean(pending[group.workspaceIdentity]),
);
if (preview) setPending((current) => ({ ...current, [group.workspaceIdentity]: preview }));
else {
setPending((current) => {
const next = { ...current };
delete next[group.workspaceIdentity];
return next;
});
}
} finally {
setBusyIdentity(null);
}
}
return (
<div className="actor-row">
<div className="actor-details">
Expand All @@ -92,6 +130,31 @@ function LegacySharedReviewRow({
{group.suggestionReason ? (
<span className="peer-meta">{group.suggestionReason}</span>
) : null}
{pending[group.workspaceIdentity] ? (
<span className="peer-meta" role="alert">
{pending[group.workspaceIdentity].warning} This will reassign{" "}
{pending[group.workspaceIdentity].reassignable_memory_count.toLocaleString()} of{" "}
{pending[group.workspaceIdentity].memory_count.toLocaleString()} memories
{pending[group.workspaceIdentity].skipped_memory_count
? `; ${pending[group.workspaceIdentity].skipped_memory_count.toLocaleString()} peer-owned copies will be left unchanged`
: ""}
.
</span>
) : null}
{group.suggestedScopeId && onReassign ? (
<button
type="button"
className="settings-button"
disabled={busyIdentity != null}
onClick={() => void applyGroup(group)}
>
{busyIdentity === group.workspaceIdentity
? "Reassigning…"
: pending[group.workspaceIdentity]
? "I understand, reassign memories"
: "Review suggested reassignment"}
</button>
) : null}
</li>
))}
</ul>
Expand All @@ -109,13 +172,18 @@ function LegacySharedReviewRow({
export function SyncSharingReview({
items,
legacyReview,
onLegacyReassign,
onLegacyReview,
onReview,
}: SyncSharingReviewProps) {
return (
<>
{legacyReview ? (
<LegacySharedReviewRow item={legacyReview} onReview={onLegacyReview ?? onReview} />
<LegacySharedReviewRow
item={legacyReview}
onReassign={onLegacyReassign}
onReview={onLegacyReview ?? onReview}
/>
) : null}
{items.map((item) => (
<SharingReviewRow
Expand Down
35 changes: 35 additions & 0 deletions packages/ui/src/tabs/sync/team-sync/render/sharing-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,16 @@
* the "Review" CTA back to the Feed tab filtered by the current actor. */

import { h } from "preact";
import * as api from "../../../../lib/api";
import type {
LegacySharedReviewReassignmentPreview,
LegacySharedReviewReassignmentResult,
} from "../../../../lib/api/sync";
import { showGlobalNotice } from "../../../../lib/notice";
import { setFeedScopeFilter, state } from "../../../../lib/state";
import { clearSyncMount, renderIntoSyncMount } from "../../components/render-root";
import {
type SyncLegacySharedReviewGroup,
SyncSharingReview,
type SyncSharingReviewItem,
} from "../../components/sync-sharing-review";
Expand All @@ -20,6 +27,33 @@ function openProjectsReview() {
window.location.hash = "projects";
}

async function reassignLegacySharedReviewGroup(
group: SyncLegacySharedReviewGroup,
scopeId: string,
confirmedOldCopies: boolean,
): Promise<LegacySharedReviewReassignmentPreview | null> {
try {
const result = (await api.reassignLegacySharedReviewGroup({
confirmed_old_copies: confirmedOldCopies,
scope_id: scopeId,
workspace_identity: group.workspaceIdentity,
})) as LegacySharedReviewReassignmentResult;
state.lastSyncLegacySharedReview = result.legacy_shared_review ?? null;
showGlobalNotice(
`Reassigned ${result.reassigned_memory_count.toLocaleString()} legacy review memor${result.reassigned_memory_count === 1 ? "y" : "ies"} to ${result.target_scope_label}.`,
);
renderSyncSharingReview();
return null;
} catch (error) {
if (error instanceof api.LegacySharedReviewConfirmationError) return error.preview;
showGlobalNotice(
error instanceof Error ? error.message : "Unable to reassign legacy review memories.",
"warning",
);
return null;
}
}

export function renderSyncSharingReview() {
const panel = document.getElementById("syncSharingReview");
const meta = document.getElementById("syncSharingReviewMeta");
Expand Down Expand Up @@ -79,6 +113,7 @@ export function renderSyncSharingReview() {
h(SyncSharingReview, {
items: reviewItems,
legacyReview,
onLegacyReassign: reassignLegacySharedReviewGroup,
onLegacyReview: openProjectsReview,
onReview: openFeedSharingReview,
}),
Expand Down
Loading