Merge branches 'feat/skills-gitops-complete', 'feat/company-portability-complete', 'feat/board-approval-markdown' and 'fix/remove-paperclip-dev-skill' into local

This commit is contained in:
2026-05-01 19:26:56 -04:00
19 changed files with 1170 additions and 335 deletions
+60
View File
@@ -17,6 +17,15 @@ import { authApi } from "../api/auth";
import { companiesApi } from "../api/companies";
import { projectsApi } from "../api/projects";
import { Button } from "@/components/ui/button";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { MarkdownBody } from "../components/MarkdownBody";
@@ -603,6 +612,8 @@ export function CompanyExport() {
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [checkedFiles, setCheckedFiles] = useState<Set<string>>(new Set());
const [treeSearch, setTreeSearch] = useState("");
const [includeSecrets, setIncludeSecrets] = useState(false);
const [secretsConfirmOpen, setSecretsConfirmOpen] = useState(false);
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
const savedExpandedRef = useRef<Set<string> | null>(null);
const initialFileFromUrl = useRef(filePathFromLocation(location.pathname));
@@ -731,6 +742,7 @@ export function CompanyExport() {
include: { company: true, agents: true, projects: true, issues: true },
selectedFiles: Array.from(checkedFiles).sort(),
sidebarOrder,
includeSecrets,
}),
onSuccess: (result) => {
const resultCheckedFiles = new Set(Object.keys(result.files));
@@ -945,6 +957,11 @@ export function CompanyExport() {
{warnings.length} warning{warnings.length === 1 ? "" : "s"}
</span>
)}
{includeSecrets && (
<span className="rounded-md border border-amber-500/30 bg-amber-500/5 px-2 py-0.5 text-xs text-amber-500">
Secrets included
</span>
)}
</div>
<Button
size="sm"
@@ -974,6 +991,29 @@ export function CompanyExport() {
<div className="border-b border-border px-4 py-3 shrink-0">
<h2 className="text-base font-semibold">Package files</h2>
</div>
<div className="border-b border-border px-4 py-2.5 shrink-0">
<div className="flex items-center gap-2 text-sm">
<ToggleSwitch
checked={includeSecrets}
onCheckedChange={(checked) => {
if (checked) {
setSecretsConfirmOpen(true);
} else {
setIncludeSecrets(false);
}
}}
/>
<span className="cursor-pointer text-muted-foreground hover:text-foreground transition-colors" onClick={() => {
if (includeSecrets) {
setIncludeSecrets(false);
} else {
setSecretsConfirmOpen(true);
}
}}>
Include secrets
</span>
</div>
</div>
<div className="border-b border-border px-3 py-2 shrink-0">
<div className="flex items-center gap-2 rounded-md border border-border px-2 py-1">
<Search className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
@@ -1014,6 +1054,26 @@ export function CompanyExport() {
<ExportPreviewPane selectedFile={selectedFile} content={previewContent} allFiles={effectiveFiles} onSkillClick={handleSkillClick} />
</div>
</div>
{/* Secrets confirmation dialog */}
<Dialog open={secretsConfirmOpen} onOpenChange={setSecretsConfirmOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Include secrets?</DialogTitle>
<DialogDescription>
Secrets will be exported as plaintext in the package file. Handle the exported package with care.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setSecretsConfirmOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={() => { setIncludeSecrets(true); setSecretsConfirmOpen(false); }}>
Include secrets
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+19
View File
@@ -866,6 +866,13 @@ export function CompanyImport() {
title: "Import complete",
body: `${result.company.name}: ${result.agents.length} agent${result.agents.length === 1 ? "" : "s"} processed.`,
});
if (result.warnings.some((w) => w.includes("could not be decrypted") || w.toLowerCase().includes("failed to create secret"))) {
pushToast({
tone: "warn",
title: "Secrets import warning",
body: "Some secrets could not be decrypted. Review warnings and recreate manually.",
});
}
// Force a fresh dashboard load so newly imported agents are immediately visible.
window.location.assign(`/${importedCompany.issuePrefix}/dashboard`);
},
@@ -1309,6 +1316,18 @@ export function CompanyImport() {
</div>
)}
{/* Secrets info */}
{importPreview.manifest.secrets && importPreview.manifest.secrets.length > 0 && (
<div className="mx-5 mt-3 rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<div className="text-xs font-medium text-amber-500 mb-1">Secrets to import</div>
{importPreview.manifest.secrets.map((s) => (
<div key={s.name} className="text-xs text-amber-500">
{s.name}{s.provider !== "local_encrypted" ? ` (${s.provider})` : ""}
</div>
))}
</div>
)}
{/* Errors */}
{importPreview.errors.length > 0 && (
<div className="mx-5 mt-3 rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3">
+123 -2
View File
@@ -52,6 +52,7 @@ import {
RefreshCw,
Save,
Search,
ShieldCheck,
Trash2,
} from "lucide-react";
@@ -487,6 +488,103 @@ function SkillList({
);
}
function SkillAuthSection({
companyId,
skillId,
hasAuth,
}: {
companyId: string;
skillId: string;
hasAuth: boolean;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const [editing, setEditing] = useState(false);
const [token, setToken] = useState("");
const updateAuth = useMutation({
mutationFn: (authToken: string | null) =>
companySkillsApi.updateAuth(companyId, skillId, authToken),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.detail(companyId, skillId) });
setEditing(false);
setToken("");
pushToast({ tone: "success", title: "Auth updated" });
},
onError: (error) => {
pushToast({
tone: "error",
title: "Failed to update auth",
body: error instanceof Error ? error.message : "Unknown error",
});
},
});
return (
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Auth</span>
{!editing ? (
<>
{hasAuth ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setEditing(true)}
>
<ShieldCheck className="mr-1.5 h-3.5 w-3.5" />
PAT configured
</Button>
<button
className="inline-flex items-center text-muted-foreground/50 hover:text-destructive transition-colors"
onClick={() => updateAuth.mutate(null)}
disabled={updateAuth.isPending}
title="Remove PAT"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setEditing(true)}
>
Add PAT
</Button>
)}
</>
) : (
<>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="GitHub Personal Access Token"
className="flex-1 min-w-[200px] rounded-md border border-border px-2 py-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/50"
autoComplete="off"
autoFocus
/>
<Button
size="sm"
onClick={() => updateAuth.mutate(token.trim())}
disabled={!token.trim() || updateAuth.isPending}
>
{updateAuth.isPending ? "Saving..." : "Save"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setEditing(false); setToken(""); }}
>
Cancel
</Button>
</>
)}
</div>
);
}
function SkillPane({
loading,
detail,
@@ -614,6 +712,13 @@ function SkillPane({
)}
</span>
</div>
{(detail.sourceType === "github" || detail.sourceType === "skills_sh") && (
<SkillAuthSection
companyId={detail.companyId}
skillId={detail.id}
hasAuth={Boolean((detail.metadata as Record<string, unknown> | null)?.sourceAuthSecretId)}
/>
)}
{detail.sourceType === "github" && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Pin</span>
@@ -762,6 +867,7 @@ export function CompanySkills() {
const { pushToast } = useToastActions();
const [skillFilter, setSkillFilter] = useState("");
const [source, setSource] = useState("");
const [importAuthToken, setImportAuthToken] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [emptySourceHelpOpen, setEmptySourceHelpOpen] = useState(false);
const [expandedSkillId, setExpandedSkillId] = useState<string | null>(null);
@@ -887,7 +993,8 @@ export function CompanySkills() {
}
const importSkill = useMutation({
mutationFn: (importSource: string) => companySkillsApi.importFromSource(selectedCompanyId!, importSource),
mutationFn: ({ importSource, authToken }: { importSource: string; authToken?: string }) =>
companySkillsApi.importFromSource(selectedCompanyId!, importSource, authToken),
onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) });
if (result.imported[0]) navigate(skillRoute(result.imported[0].id));
@@ -900,6 +1007,7 @@ export function CompanySkills() {
pushToast({ tone: "warn", title: "Import warnings", body: result.warnings[0] });
}
setSource("");
setImportAuthToken("");
},
onError: (error) => {
pushToast({
@@ -1073,7 +1181,8 @@ export function CompanySkills() {
setEmptySourceHelpOpen(true);
return;
}
importSkill.mutate(trimmedSource);
const token = importAuthToken.trim() || undefined;
importSkill.mutate({ importSource: trimmedSource, authToken: token });
}
return (
@@ -1220,6 +1329,18 @@ export function CompanySkills() {
{importSkill.isPending ? <RefreshCw className="h-4 w-4 animate-spin" /> : "Add"}
</Button>
</div>
{source.trim().length > 0 && /github\.com|^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+/.test(source.trim()) && (
<div className="mt-1 flex items-center gap-2 border-b border-border pb-2">
<input
type="password"
value={importAuthToken}
onChange={(event) => setImportAuthToken(event.target.value)}
placeholder="GitHub PAT (optional, for private repos)"
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
autoComplete="off"
/>
</div>
)}
{scanStatusMessage && (
<p className="mt-3 text-xs text-muted-foreground">
{scanStatusMessage}