feat(skills): add metadata and branch fields to skill packs and marketplace skills
This commit is contained in:
@@ -9,8 +9,10 @@
|
||||
* Payload used to register a pack URL in the organization.
|
||||
*/
|
||||
export interface SkillPackCreate {
|
||||
branch?: string;
|
||||
description?: string | null;
|
||||
name?: string | null;
|
||||
metadata?: Record<string, object>;
|
||||
/** @minLength 1 */
|
||||
source_url: string;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ export interface SkillPackRead {
|
||||
created_at: string;
|
||||
description?: string | null;
|
||||
id: string;
|
||||
branch: string;
|
||||
name: string;
|
||||
organization_id: string;
|
||||
metadata: Record<string, object>;
|
||||
skill_count?: number;
|
||||
source_url: string;
|
||||
updated_at: string;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
export interface SkillPackSyncResponse {
|
||||
created: number;
|
||||
ok?: boolean;
|
||||
warnings: string[];
|
||||
pack_id: string;
|
||||
synced: number;
|
||||
updated: number;
|
||||
|
||||
@@ -73,12 +73,17 @@ export default function EditSkillPackPage() {
|
||||
sourceUrl: pack.source_url,
|
||||
name: pack.name,
|
||||
description: pack.description ?? "",
|
||||
branch: pack.branch || "main",
|
||||
}}
|
||||
sourceLabel="Pack URL"
|
||||
nameLabel="Pack name (optional)"
|
||||
descriptionLabel="Pack description (optional)"
|
||||
branchLabel="Pack branch (optional)"
|
||||
branchPlaceholder="main"
|
||||
showBranch
|
||||
descriptionPlaceholder="Short summary shown in the packs list."
|
||||
requiredUrlMessage="Pack URL is required."
|
||||
invalidUrlMessage="Pack URL must be a GitHub repository URL (https://github.com/<owner>/<repo>)."
|
||||
submitLabel="Save changes"
|
||||
submittingLabel="Saving..."
|
||||
isSubmitting={saveMutation.isPending}
|
||||
@@ -90,6 +95,8 @@ export default function EditSkillPackPage() {
|
||||
source_url: values.sourceUrl,
|
||||
name: values.name || undefined,
|
||||
description: values.description || undefined,
|
||||
branch: values.branch || "main",
|
||||
metadata: pack.metadata || {},
|
||||
},
|
||||
});
|
||||
if (result.status !== 200) {
|
||||
|
||||
@@ -36,7 +36,11 @@ export default function NewSkillPackPage() {
|
||||
nameLabel="Pack name (optional)"
|
||||
descriptionLabel="Pack description (optional)"
|
||||
descriptionPlaceholder="Short summary shown in the packs list."
|
||||
branchLabel="Pack branch (optional)"
|
||||
branchPlaceholder="main"
|
||||
showBranch
|
||||
requiredUrlMessage="Pack URL is required."
|
||||
invalidUrlMessage="Pack URL must be a GitHub repository URL (https://github.com/<owner>/<repo>)."
|
||||
submitLabel="Add pack"
|
||||
submittingLabel="Adding..."
|
||||
isSubmitting={createMutation.isPending}
|
||||
@@ -47,6 +51,8 @@ export default function NewSkillPackPage() {
|
||||
source_url: values.sourceUrl,
|
||||
name: values.name || undefined,
|
||||
description: values.description || undefined,
|
||||
branch: values.branch || "main",
|
||||
metadata: {},
|
||||
},
|
||||
});
|
||||
if (result.status !== 200) {
|
||||
|
||||
@@ -24,7 +24,13 @@ import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||
import { useOrganizationMembership } from "@/lib/use-organization-membership";
|
||||
import { useUrlSorting } from "@/lib/use-url-sorting";
|
||||
|
||||
const PACKS_SORTABLE_COLUMNS = ["name", "source_url", "skill_count", "updated_at"];
|
||||
const PACKS_SORTABLE_COLUMNS = [
|
||||
"name",
|
||||
"source_url",
|
||||
"branch",
|
||||
"skill_count",
|
||||
"updated_at",
|
||||
];
|
||||
|
||||
export default function SkillsPacksPage() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -32,6 +38,9 @@ export default function SkillsPacksPage() {
|
||||
const { isAdmin } = useOrganizationMembership(isSignedIn);
|
||||
const [deleteTarget, setDeleteTarget] = useState<SkillPackRead | null>(null);
|
||||
const [syncingPackIds, setSyncingPackIds] = useState<Set<string>>(new Set());
|
||||
const [isSyncingAll, setIsSyncingAll] = useState(false);
|
||||
const [syncAllError, setSyncAllError] = useState<string | null>(null);
|
||||
const [syncWarnings, setSyncWarnings] = useState<string[]>([]);
|
||||
|
||||
const { sorting, onSortingChange } = useUrlSorting({
|
||||
allowedColumnIds: PACKS_SORTABLE_COLUMNS,
|
||||
@@ -91,7 +100,9 @@ export default function SkillsPacksPage() {
|
||||
};
|
||||
|
||||
const handleSyncPack = async (pack: SkillPackRead) => {
|
||||
if (syncingPackIds.has(pack.id)) return;
|
||||
if (isSyncingAll || syncingPackIds.has(pack.id)) return;
|
||||
setSyncAllError(null);
|
||||
setSyncWarnings([]);
|
||||
|
||||
setSyncingPackIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
@@ -99,9 +110,10 @@ export default function SkillsPacksPage() {
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await syncMutation.mutateAsync({
|
||||
const response = await syncMutation.mutateAsync({
|
||||
packId: pack.id,
|
||||
});
|
||||
setSyncWarnings(response.data.warnings ?? []);
|
||||
} finally {
|
||||
setSyncingPackIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
@@ -111,6 +123,54 @@ export default function SkillsPacksPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncAllPacks = async () => {
|
||||
if (!isAdmin || isSyncingAll || syncingPackIds.size > 0 || packs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSyncAllError(null);
|
||||
setSyncWarnings([]);
|
||||
setIsSyncingAll(true);
|
||||
|
||||
try {
|
||||
let hasFailure = false;
|
||||
|
||||
for (const pack of packs) {
|
||||
if (!pack.id) continue;
|
||||
setSyncingPackIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
next.add(pack.id);
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await syncMutation.mutateAsync({ packId: pack.id });
|
||||
setSyncWarnings((previous) => [
|
||||
...previous,
|
||||
...(response.data.warnings ?? []),
|
||||
]);
|
||||
} catch {
|
||||
hasFailure = true;
|
||||
} finally {
|
||||
setSyncingPackIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
next.delete(pack.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFailure) {
|
||||
setSyncAllError("Some skill packs failed to sync. Please try again.");
|
||||
}
|
||||
} finally {
|
||||
setIsSyncingAll(false);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: packsQueryKey,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardPageLayout
|
||||
@@ -122,12 +182,31 @@ export default function SkillsPacksPage() {
|
||||
description={`${packs.length} pack${packs.length === 1 ? "" : "s"} configured.`}
|
||||
headerActions={
|
||||
isAdmin ? (
|
||||
<Link
|
||||
href="/skills/packs/new"
|
||||
className={buttonVariants({ variant: "primary", size: "md" })}
|
||||
>
|
||||
Add pack
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "md",
|
||||
})}
|
||||
disabled={
|
||||
isSyncingAll ||
|
||||
syncingPackIds.size > 0 ||
|
||||
packs.length === 0
|
||||
}
|
||||
onClick={() => {
|
||||
void handleSyncAllPacks();
|
||||
}}
|
||||
>
|
||||
{isSyncingAll ? "Syncing all..." : "Sync all"}
|
||||
</button>
|
||||
<Link
|
||||
href="/skills/packs/new"
|
||||
className={buttonVariants({ variant: "primary", size: "md" })}
|
||||
>
|
||||
Add pack
|
||||
</Link>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
isAdmin={isAdmin}
|
||||
@@ -167,6 +246,18 @@ export default function SkillsPacksPage() {
|
||||
{syncMutation.error ? (
|
||||
<p className="text-sm text-rose-600">{syncMutation.error.message}</p>
|
||||
) : null}
|
||||
{syncAllError ? (
|
||||
<p className="text-sm text-rose-600">{syncAllError}</p>
|
||||
) : null}
|
||||
{syncWarnings.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{syncWarnings.map((warning) => (
|
||||
<p key={warning} className="text-sm text-amber-600">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DashboardPageLayout>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ type MarketplaceSkillFormValues = {
|
||||
sourceUrl: string;
|
||||
name: string;
|
||||
description: string;
|
||||
branch: string;
|
||||
};
|
||||
|
||||
type MarketplaceSkillFormProps = {
|
||||
@@ -21,9 +22,14 @@ type MarketplaceSkillFormProps = {
|
||||
namePlaceholder?: string;
|
||||
descriptionLabel?: string;
|
||||
descriptionPlaceholder?: string;
|
||||
branchLabel?: string;
|
||||
branchPlaceholder?: string;
|
||||
defaultBranch?: string;
|
||||
requiredUrlMessage?: string;
|
||||
invalidUrlMessage?: string;
|
||||
submitLabel: string;
|
||||
submittingLabel: string;
|
||||
showBranch?: boolean;
|
||||
isSubmitting: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: MarketplaceSkillFormValues) => Promise<void>;
|
||||
@@ -33,6 +39,7 @@ const DEFAULT_VALUES: MarketplaceSkillFormValues = {
|
||||
sourceUrl: "",
|
||||
name: "",
|
||||
description: "",
|
||||
branch: "main",
|
||||
};
|
||||
|
||||
const extractErrorMessage = (error: unknown, fallback: string) => {
|
||||
@@ -51,7 +58,12 @@ export function MarketplaceSkillForm({
|
||||
namePlaceholder = "Deploy Helper",
|
||||
descriptionLabel = "Description (optional)",
|
||||
descriptionPlaceholder = "Short summary shown in the marketplace.",
|
||||
branchLabel = "Branch (optional)",
|
||||
branchPlaceholder = "main",
|
||||
defaultBranch = "main",
|
||||
showBranch = false,
|
||||
requiredUrlMessage = "Skill URL is required.",
|
||||
invalidUrlMessage = "Skill URL must be a GitHub repository URL (https://github.com/<owner>/<repo>).",
|
||||
submitLabel,
|
||||
submittingLabel,
|
||||
isSubmitting,
|
||||
@@ -59,11 +71,30 @@ export function MarketplaceSkillForm({
|
||||
onSubmit,
|
||||
}: MarketplaceSkillFormProps) {
|
||||
const resolvedInitial = initialValues ?? DEFAULT_VALUES;
|
||||
const normalizedDefaultBranch = defaultBranch.trim() || "main";
|
||||
const [sourceUrl, setSourceUrl] = useState(resolvedInitial.sourceUrl);
|
||||
const [name, setName] = useState(resolvedInitial.name);
|
||||
const [description, setDescription] = useState(resolvedInitial.description);
|
||||
const [branch, setBranch] = useState(
|
||||
resolvedInitial.branch?.trim() || normalizedDefaultBranch,
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const isValidSourceUrl = (value: string) => {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
if (parsed.hostname !== "github.com") return false;
|
||||
const parts = parsed.pathname
|
||||
.split("/")
|
||||
.map((segment) => segment.trim())
|
||||
.filter((segment) => segment.length > 0);
|
||||
return parts.length >= 2;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const normalizedUrl = sourceUrl.trim();
|
||||
@@ -72,6 +103,11 @@ export function MarketplaceSkillForm({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidSourceUrl(normalizedUrl)) {
|
||||
setErrorMessage(invalidUrlMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
@@ -79,6 +115,7 @@ export function MarketplaceSkillForm({
|
||||
sourceUrl: normalizedUrl,
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
branch: branch.trim() || normalizedDefaultBranch,
|
||||
});
|
||||
} catch (error) {
|
||||
setErrorMessage(extractErrorMessage(error, "Unable to save skill."));
|
||||
@@ -112,6 +149,24 @@ export function MarketplaceSkillForm({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showBranch ? (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="skill-branch"
|
||||
className="text-xs font-semibold uppercase tracking-wider text-slate-500"
|
||||
>
|
||||
{branchLabel}
|
||||
</label>
|
||||
<Input
|
||||
id="skill-branch"
|
||||
value={branch}
|
||||
onChange={(event) => setBranch(event.target.value)}
|
||||
placeholder={branchPlaceholder}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="skill-name"
|
||||
|
||||
@@ -83,6 +83,11 @@ export function SkillPacksTable({
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "branch",
|
||||
header: "Branch",
|
||||
cell: ({ row }) => <p className="text-sm text-slate-900">{row.original.branch || "main"}</p>,
|
||||
},
|
||||
{
|
||||
accessorKey: "skill_count",
|
||||
header: "Skills",
|
||||
|
||||
Reference in New Issue
Block a user