Skip to content
Draft
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
105 changes: 105 additions & 0 deletions convex/githubSkillSync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,111 @@ describe("configurePublicGitHubSkillSourceHandler", () => {
);
});

it("requires an explicit selection when a new repo contains multiple skills", async () => {
const zip = zipSync({
"skills-main/skills/bundled/SKILL.md": new TextEncoder().encode("# Bundled\n"),
"skills-main/skills/vibethon/SKILL.md": new TextEncoder().encode("# Vibethon\n"),
});
const runQuery = vi.fn(async () => ({
ownerUserId: "users:publisher-owner",
existingSource: null,
official: true,
}));
const runMutation = vi.fn();
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
full_name: "Vibethon/skills",
private: false,
visibility: "public",
default_branch: "main",
disabled: false,
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ sha: "2".repeat(40) }),
})
.mockResolvedValueOnce({
ok: true,
headers: new Headers({ "content-length": String(zip.byteLength) }),
body: null,
arrayBuffer: async () => zip.buffer.slice(zip.byteOffset, zip.byteOffset + zip.byteLength),
});

await expect(
configurePublicGitHubSkillSourceHandler(
{ runQuery, runMutation, auth: { getUserIdentity: vi.fn() } } as never,
{
ownerPublisherId: "publishers:vibethon" as never,
repo: "Vibethon/skills",
},
fetchMock as never,
{ userId: "users:actor" as never },
),
).rejects.toThrow(/select at least one skill/i);
expect(runMutation).not.toHaveBeenCalled();
});

it("preserves all-skill behavior for legacy sources without a saved selection", async () => {
const zip = zipSync({
"skills-main/skills/bundled/SKILL.md": new TextEncoder().encode("# Bundled\n"),
"skills-main/skills/vibethon/SKILL.md": new TextEncoder().encode("# Vibethon\n"),
});
const runQuery = vi.fn(async () => ({
ownerUserId: "users:publisher-owner",
existingSource: {
_id: "githubSkillSources:legacy",
repo: "Vibethon/skills",
ownerPublisherId: "publishers:vibethon",
defaultBranch: "main",
},
official: true,
}));
const runMutation = vi.fn(async () => ({ ok: true, stats: { discovered: 2 } }));
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
full_name: "Vibethon/skills",
private: false,
visibility: "public",
default_branch: "main",
disabled: false,
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ sha: "3".repeat(40) }),
})
.mockResolvedValueOnce({
ok: true,
headers: new Headers({ "content-length": String(zip.byteLength) }),
body: null,
arrayBuffer: async () => zip.buffer.slice(zip.byteOffset, zip.byteOffset + zip.byteLength),
});

await configurePublicGitHubSkillSourceHandler(
{ runQuery, runMutation, auth: { getUserIdentity: vi.fn() } } as never,
{
ownerPublisherId: "publishers:vibethon" as never,
repo: "Vibethon/skills",
},
fetchMock as never,
{ userId: "users:actor" as never },
);

const syncArgs = (runMutation.mock.calls[0] as unknown[] | undefined)?.[1] as Record<
string,
unknown
>;
expect(syncArgs).not.toHaveProperty("selectedSkillPaths");
expect((syncArgs.snapshot as { skills: unknown[] }).skills).toHaveLength(2);
});

it("rejects non-official publishers before fetching skill contents", async () => {
const runQuery = vi.fn(async () => ({
ownerUserId: "users:publisher-owner",
Expand Down
108 changes: 106 additions & 2 deletions convex/githubSkillSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type DiscoveredGitHubSkill,
type DisplayManifestStatus,
githubBackedSkillModeration,
normalizeSelectedSkillPaths,
type GitHubSkillScanStatus,
type GitHubSkillSourceMetadataSnapshot,
type GitHubSkillSourceSnapshot,
Expand Down Expand Up @@ -45,7 +46,7 @@ const MAX_SOURCE_SYNC_BATCH_SIZE = 50;

type SourceForSync = Pick<
Doc<"githubSkillSources">,
"_id" | "repo" | "ownerPublisherId" | "defaultBranch"
"_id" | "repo" | "ownerPublisherId" | "defaultBranch" | "selectedSkillPaths"
>;

type SourceForSyncPage = {
Expand Down Expand Up @@ -121,6 +122,24 @@ type GitHubSkillSourceSetupContext = {
official: boolean;
};

type GitHubSkillSourcePreview = {
ok: true;
repo: string;
defaultBranch: string;
commit: string;
manifestStatus: DisplayManifestStatus;
existingSourceId?: Id<"githubSkillSources">;
selectedSkillPaths?: string[];
skills: Array<{
slug: string;
displayName: string;
summary?: string;
path: string;
skillCardMarkdownPath?: string;
contentHash: string;
}>;
};

type GitHubSkillVerificationTarget = {
skill: Pick<Doc<"skills">, "_id" | "slug" | "displayName" | "summary"> & {
githubPath: string;
Expand Down Expand Up @@ -395,6 +414,7 @@ export type ApplyGitHubSkillSourceSyncArgs = {
repo: string;
ownerUserId: Id<"users">;
ownerPublisherId?: Id<"publishers">;
selectedSkillPaths?: string[];
snapshot: GitHubSkillSourceMetadataSnapshot;
now?: number;
};
Expand Down Expand Up @@ -423,13 +443,17 @@ export async function applyGitHubSkillSourceSyncHandler(
}

const sourceOwnerPublisherId = args.ownerPublisherId ?? existingSource?.ownerPublisherId;
const selectedSkillPaths =
normalizeSelectedSkillPaths(args.selectedSkillPaths) ??
normalizeSelectedSkillPaths(existingSource?.selectedSkillPaths);
const sourceId =
existingSource?._id ??
(await ctx.db.insert(
"githubSkillSources",
stripUndefined({
repo,
ownerPublisherId: sourceOwnerPublisherId,
selectedSkillPaths,
createdAt: now,
updatedAt: now,
}) as Omit<Doc<"githubSkillSources">, "_id" | "_creationTime">,
Expand All @@ -443,6 +467,7 @@ export async function applyGitHubSkillSourceSyncHandler(
sourceId,
ownerUserId: args.ownerUserId,
...(sourceOwnerPublisherId ? { ownerPublisherId: sourceOwnerPublisherId } : {}),
selectedSkillPaths,
existingSkills: existingSkills.map((skill) => ({
_id: skill._id,
slug: skill.slug,
Expand Down Expand Up @@ -840,6 +865,7 @@ export const applyGitHubSkillSourceSyncInternal = internalMutation({
repo: v.string(),
ownerUserId: v.id("users"),
ownerPublisherId: v.optional(v.id("publishers")),
selectedSkillPaths: v.optional(v.array(v.string())),
snapshot: sourceSnapshotValidator,
now: v.optional(v.number()),
},
Expand Down Expand Up @@ -977,7 +1003,11 @@ export async function verifyGitHubSkillHandler(

export async function configurePublicGitHubSkillSourceHandler(
ctx: ActionCtx,
args: { ownerPublisherId: Id<"publishers">; repo: string },
args: {
ownerPublisherId: Id<"publishers">;
repo: string;
selectedSkillPaths?: string[];
},
fetcher: typeof fetch = fetch,
authOverride?: { userId: Id<"users"> },
): Promise<SyncOneResult> {
Expand All @@ -1004,31 +1034,104 @@ export async function configurePublicGitHubSkillSourceHandler(
if (snapshot.skills.length === 0) {
throw new ConvexError("No skills were found in that public GitHub repo.");
}
const hasExplicitSelection = args.selectedSkillPaths !== undefined;
const selectedSkillPaths = hasExplicitSelection
? normalizeSelectedSkillPaths(args.selectedSkillPaths)
: normalizeSelectedSkillPaths(setup.existingSource?.selectedSkillPaths);
if (hasExplicitSelection && !selectedSkillPaths) {
throw new ConvexError("Select at least one skill before adding this repo.");
}
if (!setup.existingSource && !selectedSkillPaths && snapshot.skills.length > 1) {
throw new ConvexError("Select at least one skill before adding this repo.");
}
if (selectedSkillPaths) {
const discoveredPaths = new Set(snapshot.skills.map((skill) => skill.path));
const missingPath = selectedSkillPaths.find((path) => !discoveredPaths.has(path));
if (missingPath) {
throw new ConvexError(`Selected GitHub skill is not present in the repo: ${missingPath}`);
}
}
const effectiveSelectedSkillPaths =
!setup.existingSource && !selectedSkillPaths
? [snapshot.skills[0]?.path as string]
: selectedSkillPaths;
return await applyFetchedGitHubSkillSourceSnapshot(ctx, {
sourceId: setup.existingSource?._id,
repo: metadata.repo,
ownerUserId: setup.ownerUserId,
ownerPublisherId: args.ownerPublisherId,
selectedSkillPaths: effectiveSelectedSkillPaths,
snapshot,
});
}

export async function previewPublicGitHubSkillSourceHandler(
ctx: ActionCtx,
args: { ownerPublisherId: Id<"publishers">; repo: string },
fetcher: typeof fetch = fetch,
authOverride?: { userId: Id<"users"> },
): Promise<GitHubSkillSourcePreview> {
const actor = authOverride ?? (await requireUserFromAction(ctx));
const metadata = await fetchPublicGitHubRepoMetadata(args.repo, fetcher);
const setup = (await ctx.runQuery(
internal.githubSkillSync.getPublicGitHubSkillSourceSetupContextInternal,
{
ownerPublisherId: args.ownerPublisherId,
actorUserId: actor.userId,
repo: metadata.repo,
},
)) as GitHubSkillSourceSetupContext;
if (!setup.official) {
throw new ConvexError("GitHub source sync is only available for official publishers.");
}
const snapshot = await fetchGitHubSkillSourceSnapshot(
{ repo: metadata.repo, defaultBranch: metadata.defaultBranch },
fetcher,
);
if (snapshot.skills.length === 0) {
throw new ConvexError("No skills were found in that public GitHub repo.");
}
return {
ok: true,
repo: metadata.repo,
defaultBranch: metadata.defaultBranch,
commit: snapshot.commit,
manifestStatus: snapshot.manifestStatus,
existingSourceId: setup.existingSource?._id,
selectedSkillPaths: normalizeSelectedSkillPaths(setup.existingSource?.selectedSkillPaths),
skills: snapshot.skills.map(
({ skillMarkdown: _skillMarkdown, skillCardMarkdown: _skillCardMarkdown, ...skill }) => skill,
),
};
}

export const configurePublicGitHubSkillSource: ReturnType<typeof action> = action({
args: {
ownerPublisherId: v.id("publishers"),
repo: v.string(),
selectedSkillPaths: v.optional(v.array(v.string())),
},
handler: async (ctx, args): Promise<SyncOneResult> =>
configurePublicGitHubSkillSourceHandler(ctx, args),
});

export const previewPublicGitHubSkillSource: ReturnType<typeof action> = action({
args: {
ownerPublisherId: v.id("publishers"),
repo: v.string(),
},
handler: async (ctx, args): Promise<GitHubSkillSourcePreview> =>
previewPublicGitHubSkillSourceHandler(ctx, args),
});

async function applyFetchedGitHubSkillSourceSnapshot(
ctx: ActionCtx,
args: {
sourceId?: Id<"githubSkillSources">;
repo: string;
ownerUserId: Id<"users">;
ownerPublisherId?: Id<"publishers">;
selectedSkillPaths?: string[];
snapshot: GitHubSkillSourceSnapshot;
},
) {
Expand All @@ -1039,6 +1142,7 @@ async function applyFetchedGitHubSkillSourceSnapshot(
repo: args.repo,
ownerUserId: args.ownerUserId,
ownerPublisherId: args.ownerPublisherId,
...(args.selectedSkillPaths ? { selectedSkillPaths: args.selectedSkillPaths } : {}),
snapshot: toGitHubSkillSourceMetadataSnapshot(args.snapshot),
},
)) as SyncOneResult;
Expand Down
32 changes: 32 additions & 0 deletions convex/lib/githubSkillSync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,38 @@ describe("buildGitHubSkillSourceSnapshot", () => {
});

describe("buildGitHubSkillSyncPlan", () => {
it("limits a source to its explicit selected skill paths", async () => {
const snapshot = await buildGitHubSkillSourceSnapshot({
repo: "vibethon/skills",
defaultBranch: "main",
commit: "a".repeat(40),
entries: repoEntries({
"SKILL.md": "# Bundled project skill\n",
"skills/vibethon/SKILL.md": "# Vibethon\n",
}),
});

const plan = buildGitHubSkillSyncPlan({
sourceId: "githubSkillSources:vibethon",
ownerUserId: "users:vibethon",
ownerPublisherId: "publishers:vibethon",
selectedSkillPaths: ["skills/vibethon"],
existingSkills: [],
snapshot,
now: 123,
});

expect(plan.stats.discovered).toBe(1);
expect(plan.skillInserts).toHaveLength(1);
expect(plan.skillInserts[0]?.doc).toMatchObject({
slug: "vibethon",
githubPath: "skills/vibethon",
});
expect(plan.sourcePatch).toMatchObject({
selectedSkillPaths: ["skills/vibethon"],
});
});

it("marks changed upstream content pending", async () => {
const snapshot = await buildGitHubSkillSourceSnapshot({
repo: "NVIDIA/skills",
Expand Down
Loading
Loading