mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(git): protected-branches enforcement + panel editor (#649)
projects.protected_branches existed end-to-end but nothing consulted it — the panel had no editor and the git safety checks used hardcoded sets. Now: GitService._protected_branches_for(slug) (frozenset, stripped, fail-open to the hardcoded floor with a warning log) is unioned — never replacing, only tightening — into rebase()'s refusal set, the shared _delete_remote_branch_best_effort skip set (threaded through every caller: task cleanup, PR merge/close cleanup), and sync_task_branch, which now refuses to force-push a protected-named head (the dev-facing sync_branch verb path the HTTP-only fix would have missed). Matching is exact and case-sensitive; an empty list degrades to exactly the old hardcoded behavior, pinned by union-floor regression tests (master/main stay refused regardless of the project list). Panel: chips editor for the field in the edit-project dialog (add via Enter/comma, paste-splitting on comma-separated lists, dedup, clear-to- empty persists []) with an honest tooltip scoped to what is actually enforced. Tests cover both the incumbent GitHub-App dialog suite and the new Protected Branches suite in one harness. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -267,3 +267,121 @@ describe("EditProjectDialog — GitHub App binding", () => {
|
||||
expect(call.updates.github_installation_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditProjectDialog — Protected Branches", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getCredentialsStatus.mockResolvedValue({ has_credentials: true });
|
||||
mutateAsync.mockResolvedValue(makeProject());
|
||||
useUpdateProject.mockReturnValue({ mutateAsync, isPending: false });
|
||||
});
|
||||
|
||||
it("renders the project's existing protected branches as chips", async () => {
|
||||
renderDialog(makeProject({ protected_branches: ["master", "slave"] }));
|
||||
|
||||
await screen.findByText("master");
|
||||
expect(screen.getByText("slave")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds a branch via Enter and removes another via its chip, saving both changes", async () => {
|
||||
renderDialog(makeProject({ protected_branches: ["master", "slave"] }));
|
||||
await screen.findByText("master");
|
||||
|
||||
// Remove "slave".
|
||||
fireEvent.click(screen.getByLabelText("Remove slave"));
|
||||
expect(screen.queryByText("slave")).not.toBeInTheDocument();
|
||||
|
||||
// Add "release" by typing + Enter.
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Type a branch name, press Enter",
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "release" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByText("release")).toBeInTheDocument();
|
||||
// The input clears after a successful add.
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
|
||||
|
||||
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
|
||||
const call = mutateAsync.mock.calls[0][0] as {
|
||||
updates: { protected_branches?: string[] };
|
||||
};
|
||||
expect(call.updates.protected_branches).toEqual(["master", "release"]);
|
||||
});
|
||||
|
||||
it("adding via a trailing comma also commits the chip", async () => {
|
||||
renderDialog(makeProject({ protected_branches: ["master", "slave"] }));
|
||||
await screen.findByText("master");
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Type a branch name, press Enter",
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "hotfix" } });
|
||||
fireEvent.keyDown(input, { key: "," });
|
||||
expect(screen.getByText("hotfix")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pasting a comma-separated list splits it into individual chips instead of one malformed chip", async () => {
|
||||
renderDialog(makeProject({ protected_branches: ["master", "slave"] }));
|
||||
await screen.findByText("master");
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Type a branch name, press Enter",
|
||||
);
|
||||
fireEvent.paste(input, {
|
||||
clipboardData: { getData: () => "release,hotfix,staging" },
|
||||
});
|
||||
|
||||
expect(screen.getByText("release")).toBeInTheDocument();
|
||||
expect(screen.getByText("hotfix")).toBeInTheDocument();
|
||||
expect(screen.getByText("staging")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("release,hotfix,staging"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("does not add a duplicate chip for a branch already in the list", async () => {
|
||||
renderDialog(makeProject({ protected_branches: ["master", "slave"] }));
|
||||
await screen.findByText("master");
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
"Type a branch name, press Enter",
|
||||
);
|
||||
fireEvent.change(input, { target: { value: "master" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getAllByText("master")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clearing every branch sends an explicit empty array, not an omitted field", async () => {
|
||||
renderDialog(makeProject({ protected_branches: ["master", "slave"] }));
|
||||
await screen.findByText("master");
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Remove master"));
|
||||
fireEvent.click(screen.getByLabelText("Remove slave"));
|
||||
expect(screen.queryByText("master")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("slave")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
|
||||
|
||||
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
|
||||
const call = mutateAsync.mock.calls[0][0] as {
|
||||
updates: { protected_branches?: string[] };
|
||||
};
|
||||
expect(call.updates.protected_branches).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaving the list untouched still round-trips the same branches on save", async () => {
|
||||
renderDialog(makeProject({ protected_branches: ["master", "slave"] }));
|
||||
await screen.findByText("master");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save Changes/i }));
|
||||
|
||||
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
|
||||
const call = mutateAsync.mock.calls[0][0] as {
|
||||
updates: { protected_branches?: string[] };
|
||||
};
|
||||
expect(call.updates.protected_branches).toEqual(["master", "slave"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,9 +23,10 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ConventionsTab } from "@/components/conventions/conventions-tab";
|
||||
import { Key, KeyRound, AlertTriangle } from "lucide-react";
|
||||
import { Key, KeyRound, AlertTriangle, Plus, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Team, type ProjectUpdate, type Project } from "@/types";
|
||||
import { githubAppApi } from "@/lib/api";
|
||||
@@ -165,6 +166,43 @@ function EditProjectForm({
|
||||
>(project.github_installation_id);
|
||||
const [assignedCell, setAssignedCell] = useState(project.assigned_cell);
|
||||
const [defaultBranch, setDefaultBranch] = useState(project.default_branch);
|
||||
const [protectedBranches, setProtectedBranches] = useState<string[]>(
|
||||
project.protected_branches ?? [],
|
||||
);
|
||||
const [protectedBranchInput, setProtectedBranchInput] = useState("");
|
||||
// Shared by the single Enter/comma-key add and the multi-value paste
|
||||
// handler below — trims, drops empties, and dedups against both the
|
||||
// existing list and duplicates within the same batch.
|
||||
const addProtectedBranches = (names: string[]) => {
|
||||
const cleaned = names.map((n) => n.trim()).filter(Boolean);
|
||||
if (cleaned.length === 0) return;
|
||||
setProtectedBranches((prev) => {
|
||||
const next = [...prev];
|
||||
for (const name of cleaned) {
|
||||
if (!next.includes(name)) next.push(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const addProtectedBranch = () => {
|
||||
addProtectedBranches([protectedBranchInput]);
|
||||
setProtectedBranchInput("");
|
||||
};
|
||||
const handleProtectedBranchPaste = (
|
||||
e: React.ClipboardEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const pasted = e.clipboardData.getData("text");
|
||||
// A single name (no comma) falls through to normal paste-into-input
|
||||
// behavior; only a multi-value paste is split into chips directly —
|
||||
// otherwise "release,hotfix,staging" lands as one malformed chip.
|
||||
if (!pasted.includes(",")) return;
|
||||
e.preventDefault();
|
||||
addProtectedBranches(pasted.split(","));
|
||||
setProtectedBranchInput("");
|
||||
};
|
||||
const removeProtectedBranch = (branch: string) => {
|
||||
setProtectedBranches((prev) => prev.filter((b) => b !== branch));
|
||||
};
|
||||
const [environments, setEnvironments] = useState(
|
||||
project.environments ?? null,
|
||||
);
|
||||
@@ -284,6 +322,7 @@ function EditProjectForm({
|
||||
github_installation_id: githubInstallationId,
|
||||
assigned_cell: assignedCell,
|
||||
default_branch: defaultBranch || "main",
|
||||
protected_branches: protectedBranches,
|
||||
environments,
|
||||
is_active: isActive,
|
||||
test_command: testCommand || undefined,
|
||||
@@ -571,6 +610,56 @@ function EditProjectForm({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Protected Branches */}
|
||||
<div className="grid gap-2">
|
||||
<HelpTip label="Branches the fleet refuses to rebase onto, sync (force-push) as a task's own branch, or delete on the remote, in addition to the always-protected master/main defaults — matched exactly, case-sensitive. Environment-ladder rungs get separate protection, but only for task-branch cleanup, not a PR's source-branch cleanup after merge.">
|
||||
<Label htmlFor="protected_branch_input">Protected Branches</Label>
|
||||
</HelpTip>
|
||||
{protectedBranches.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{protectedBranches.map((branch) => (
|
||||
<Badge key={branch} variant="secondary" className="gap-1 pr-1">
|
||||
{branch}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeProtectedBranch(branch)}
|
||||
aria-label={`Remove ${branch}`}
|
||||
className="rounded-full hover:bg-muted-foreground/20"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="protected_branch_input"
|
||||
value={protectedBranchInput}
|
||||
onChange={(e) => setProtectedBranchInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
addProtectedBranch();
|
||||
}
|
||||
}}
|
||||
onPaste={handleProtectedBranchPaste}
|
||||
placeholder="Type a branch name, press Enter"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addProtectedBranch}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter or comma adds a branch; click the × on a chip to remove it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Environment ladder */}
|
||||
<EnvironmentLadderEditor
|
||||
rungs={environments}
|
||||
|
||||
Reference in New Issue
Block a user