feat: add GitHub Skill Sync configuration preview

This commit is contained in:
Patrick Erichsen
2026-07-23 14:12:11 -07:00
parent edd4e01a07
commit f8af9e7a0a
9 changed files with 1669 additions and 141 deletions
+4
View File
@@ -32,6 +32,7 @@ import type * as githubOrgMemberships from "../githubOrgMemberships.js";
import type * as githubSkillSources from "../githubSkillSources.js";
import type * as githubSkillSync from "../githubSkillSync.js";
import type * as githubSkillSyncNode from "../githubSkillSyncNode.js";
import type * as githubSkillSyncSettings from "../githubSkillSyncSettings.js";
import type * as http from "../http.js";
import type * as httpApi from "../httpApi.js";
import type * as httpApiV1 from "../httpApiV1.js";
@@ -79,6 +80,7 @@ import type * as lib_githubOrgMemberships from "../lib/githubOrgMemberships.js";
import type * as lib_githubProfileSync from "../lib/githubProfileSync.js";
import type * as lib_githubSkillScans from "../lib/githubSkillScans.js";
import type * as lib_githubSkillSync from "../lib/githubSkillSync.js";
import type * as lib_githubSkillSyncSettings from "../lib/githubSkillSyncSettings.js";
import type * as lib_globalStats from "../lib/globalStats.js";
import type * as lib_httpHeaders from "../lib/httpHeaders.js";
import type * as lib_httpPathSegments from "../lib/httpPathSegments.js";
@@ -213,6 +215,7 @@ declare const fullApi: ApiFromModules<{
githubSkillSources: typeof githubSkillSources;
githubSkillSync: typeof githubSkillSync;
githubSkillSyncNode: typeof githubSkillSyncNode;
githubSkillSyncSettings: typeof githubSkillSyncSettings;
http: typeof http;
httpApi: typeof httpApi;
httpApiV1: typeof httpApiV1;
@@ -260,6 +263,7 @@ declare const fullApi: ApiFromModules<{
"lib/githubProfileSync": typeof lib_githubProfileSync;
"lib/githubSkillScans": typeof lib_githubSkillScans;
"lib/githubSkillSync": typeof lib_githubSkillSync;
"lib/githubSkillSyncSettings": typeof lib_githubSkillSyncSettings;
"lib/globalStats": typeof lib_globalStats;
"lib/httpHeaders": typeof lib_httpHeaders;
"lib/httpPathSegments": typeof lib_httpPathSegments;
+1 -1
View File
@@ -1282,7 +1282,7 @@ export async function syncGitHubSkillSourcesHandler(
};
}
async function fetchGitHubSkillSourceSnapshot(
export async function fetchGitHubSkillSourceSnapshot(
{
repo,
defaultBranch,
+323
View File
@@ -0,0 +1,323 @@
/* @vitest-environment node */
import { zipSync } from "fflate";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./lib/githubIdentity", () => ({
getGitHubProviderAccountId: vi.fn(),
}));
vi.mock("./lib/publishers", async () => {
const actual = await vi.importActual<typeof import("./lib/publishers")>("./lib/publishers");
return {
...actual,
requirePublisherRole: vi.fn(),
};
});
const { getGitHubProviderAccountId } = await import("./lib/githubIdentity");
const { requirePublisherRole } = await import("./lib/publishers");
const {
getGitHubSkillSyncPublisherContextHandler,
listGitHubSkillSyncRepositoriesHandler,
previewGitHubSkillSyncRepositoryHandler,
} = await import("./githubSkillSyncSettings");
beforeEach(() => {
vi.stubEnv("CONVEX_DEPLOYMENT", "local:clawhub");
vi.stubEnv("CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE", "test");
});
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});
describe("getGitHubSkillSyncPublisherContextHandler", () => {
it("uses the linked personal publisher's immutable GitHub provider id", async () => {
vi.mocked(requirePublisherRole).mockResolvedValue({
publisher: {
_id: "publishers:patrick",
kind: "user",
handle: "patrick",
linkedUserId: "users:patrick",
},
} as never);
vi.mocked(getGitHubProviderAccountId).mockResolvedValue("123");
await expect(
getGitHubSkillSyncPublisherContextHandler({ db: {} } as never, {
publisherId: "publishers:patrick" as never,
userId: "users:patrick" as never,
now: 100,
}),
).resolves.toEqual({
publisherId: "publishers:patrick",
publisherHandle: "patrick",
publisherKind: "user",
githubOwnerId: "123",
});
});
it("requires fresh current admin membership for a verified organization", async () => {
vi.mocked(requirePublisherRole).mockResolvedValue({
publisher: {
_id: "publishers:openclaw",
kind: "org",
handle: "openclaw",
githubOrgId: "42",
githubVerifiedAt: 50,
},
} as never);
const unique = vi.fn(async () => ({
githubOrgId: "42",
login: "openclaw",
role: "admin",
syncedAt: 90,
}));
const db = {
query: vi.fn(() => ({
withIndex: vi.fn((_name, build) => {
build({ eq: () => ({ eq: () => undefined }) });
return { unique };
}),
})),
};
await expect(
getGitHubSkillSyncPublisherContextHandler({ db } as never, {
publisherId: "publishers:openclaw" as never,
userId: "users:patrick" as never,
now: 100,
}),
).resolves.toEqual({
publisherId: "publishers:openclaw",
publisherHandle: "openclaw",
publisherKind: "org",
githubOwnerId: "42",
githubLogin: "openclaw",
});
});
});
describe("listGitHubSkillSyncRepositoriesHandler", () => {
it("lists only public repositories with the verified immutable owner id", async () => {
const runQuery = vi.fn(async () => ({
publisherId: "publishers:patrick",
publisherHandle: "patrick",
publisherKind: "user",
githubOwnerId: "123",
}));
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 123, login: "patrick-erichsen" }),
})
.mockResolvedValueOnce({
ok: true,
headers: new Headers(),
json: async () => [
{
id: 1,
full_name: "patrick-erichsen/skills",
private: false,
visibility: "public",
owner: { id: 123, login: "patrick-erichsen" },
default_branch: "main",
archived: false,
disabled: false,
fork: false,
pushed_at: "2026-07-23T12:00:00Z",
},
{
id: 2,
full_name: "someone-else/skills",
private: false,
visibility: "public",
owner: { id: 999, login: "someone-else" },
default_branch: "main",
},
{
id: 3,
full_name: "patrick-erichsen/archived-skills",
private: false,
visibility: "public",
owner: { id: 123, login: "patrick-erichsen" },
default_branch: "main",
archived: true,
disabled: false,
fork: false,
pushed_at: "2026-07-22T12:00:00Z",
},
{
id: 4,
full_name: "patrick-erichsen/forked-skills",
private: false,
visibility: "public",
owner: { id: 123, login: "patrick-erichsen" },
default_branch: "main",
archived: false,
disabled: false,
fork: true,
pushed_at: "2026-07-21T12:00:00Z",
},
],
});
await expect(
listGitHubSkillSyncRepositoriesHandler(
{ runQuery } as never,
{ publisherId: "publishers:patrick" as never },
fetchMock as never,
{ userId: "users:patrick" as never },
),
).resolves.toMatchObject({
publisher: { handle: "patrick" },
repositories: [
{
repositoryId: "1",
repo: "patrick-erichsen/skills",
defaultBranch: "main",
selectable: true,
},
{
repositoryId: "3",
repo: "patrick-erichsen/archived-skills",
defaultBranch: "main",
selectable: true,
},
{
repositoryId: "4",
repo: "patrick-erichsen/forked-skills",
defaultBranch: "main",
selectable: true,
},
],
});
});
});
describe("previewGitHubSkillSyncRepositoryHandler", () => {
it("discovers repository skills directly after canonical ownership verification", async () => {
const zip = zipSync({
"skills-main/skills/html/SKILL.md": new TextEncoder().encode(
"---\nname: HTML\ndescription: Build HTML artifacts\n---\n",
),
"skills-main/skills/off-leaderboard/SKILL.md": new TextEncoder().encode(
"---\nname: Off Leaderboard\n---\n",
),
});
const classifiedItems = [
{
slug: "html",
displayName: "HTML",
path: "skills/html",
contentHash: "hash-html",
classification: "replacement",
eligible: true,
destination: {
skillId: "skills:html",
ownerPublisherId: "publishers:patrick",
ownerHandle: "patrick",
slug: "html",
displayName: "HTML",
},
},
{
slug: "off-leaderboard",
displayName: "Off Leaderboard",
path: "skills/off-leaderboard",
contentHash: "hash-off-leaderboard",
classification: "new-destination",
eligible: true,
destination: null,
},
];
const runQuery = vi
.fn()
.mockResolvedValueOnce({
publisherId: "publishers:patrick",
publisherHandle: "patrick",
publisherKind: "user",
githubOwnerId: "123",
})
.mockResolvedValueOnce(classifiedItems);
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 77,
full_name: "patrick-erichsen/skills",
private: false,
visibility: "public",
owner: { id: 123, login: "patrick-erichsen" },
default_branch: "main",
archived: true,
disabled: false,
fork: true,
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ sha: "a".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),
});
const result = await previewGitHubSkillSyncRepositoryHandler(
{ runQuery } as never,
{
publisherId: "publishers:patrick" as never,
repo: "patrick-aerichsen/skills",
},
fetchMock as never,
{ userId: "users:patrick" as never },
);
expect(result).toMatchObject({
publisher: { handle: "patrick" },
repository: {
requestedRepo: "patrick-aerichsen/skills",
repositoryId: "77",
repo: "patrick-erichsen/skills",
redirected: true,
commit: "a".repeat(40),
},
summary: {
total: 2,
newDestinations: 1,
replacements: 1,
unavailable: 0,
conflicts: 0,
},
items: classifiedItems,
});
expect(runQuery).toHaveBeenCalledTimes(2);
});
it("fails closed before GitHub requests when the rollout capability is off", async () => {
vi.stubEnv("CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE", "off");
const fetchMock = vi.fn();
const runQuery = vi.fn();
await expect(
previewGitHubSkillSyncRepositoryHandler(
{ runQuery } as never,
{
publisherId: "publishers:patrick" as never,
repo: "patrick-erichsen/skills",
},
fetchMock as never,
{ userId: "users:patrick" as never },
),
).rejects.toThrow(/rollout is disabled/i);
expect(fetchMock).not.toHaveBeenCalled();
expect(runQuery).not.toHaveBeenCalled();
});
});
+550
View File
@@ -0,0 +1,550 @@
import { ConvexError, v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { ActionCtx, QueryCtx } from "./_generated/server";
import { action, internalQuery } from "./functions";
import { fetchGitHubSkillSourceSnapshot } from "./githubSkillSync";
import { requireUserFromAction } from "./lib/access";
import { buildGitHubApiHeaders } from "./lib/githubAuth";
import { getGitHubProviderAccountId } from "./lib/githubIdentity";
import { GITHUB_ORG_MEMBERSHIP_VERIFICATION_MAX_AGE_MS } from "./lib/githubOrgMemberships";
import {
classifyGitHubSkillSyncPreviewItem,
type GitHubSkillSyncDiscoveredSkill,
type GitHubSkillSyncPreviewDestination,
type GitHubSkillSyncPreviewItem,
} from "./lib/githubSkillSyncSettings";
import { requirePublisherRole } from "./lib/publishers";
import { assertGitHubSkillSyncRuntimeEnabled } from "./lib/rolloutCapabilities";
import {
getSkillBySlugForPublisher,
getSkillSlugAliasBySlugForPublisher,
} from "./lib/skills/slugResolution";
import { assertValidSkillSlug } from "./lib/skillSlugValidator";
const GITHUB_API = "https://api.github.com";
const DEFAULT_REPOSITORY_PAGE_SIZE = 100;
const MAX_REPOSITORY_PAGE_SIZE = 100;
type PublisherContext = {
publisherId: Id<"publishers">;
publisherHandle: string;
publisherKind: "user" | "org";
githubOwnerId: string;
githubLogin?: string;
};
type GitHubRepositoryMetadata = {
repositoryId: string;
repo: string;
ownerId: string;
ownerLogin: string;
defaultBranch: string;
archived: boolean;
disabled: boolean;
fork: boolean;
};
type GitHubRepositoryListItem = GitHubRepositoryMetadata & {
pushedAt: string | null;
selectable: boolean;
unavailableReason: "disabled" | null;
};
type GitHubSkillSyncRepositoryListResult = {
publisher: {
_id: Id<"publishers">;
handle: string;
kind: "user" | "org";
};
page: number;
perPage: number;
hasMore: boolean;
repositories: GitHubRepositoryListItem[];
};
type GitHubSkillSyncRepositoryPreviewResult = {
publisher: {
_id: Id<"publishers">;
handle: string;
kind: "user" | "org";
};
repository: {
requestedRepo: string;
repositoryId: string;
repo: string;
redirected: boolean;
defaultBranch: string;
commit: string;
};
summary: {
total: number;
newDestinations: number;
replacements: number;
unavailable: number;
conflicts: number;
};
items: GitHubSkillSyncPreviewItem[];
};
const discoveredSkillValidator = v.object({
slug: v.string(),
displayName: v.string(),
path: v.string(),
contentHash: v.string(),
});
function parseGitHubNumericId(value: unknown, message: string) {
const normalized =
typeof value === "number" && Number.isSafeInteger(value) && value > 0
? String(value)
: typeof value === "string" && /^[1-9]\d*$/.test(value.trim())
? value.trim()
: "";
if (!normalized) throw new ConvexError(message);
return normalized;
}
export async function getGitHubSkillSyncPublisherContextHandler(
ctx: QueryCtx,
args: {
publisherId: Id<"publishers">;
userId: Id<"users">;
now?: number;
},
): Promise<PublisherContext> {
const { publisher } = await requirePublisherRole(ctx, {
publisherId: args.publisherId,
userId: args.userId,
allowed: ["admin"],
});
if (publisher.kind === "user") {
if (publisher.linkedUserId !== args.userId) throw new ConvexError("Forbidden");
const githubOwnerId = parseGitHubNumericId(
await getGitHubProviderAccountId(ctx, args.userId),
"Reconnect GitHub to verify your personal account",
);
return {
publisherId: publisher._id,
publisherHandle: publisher.handle,
publisherKind: "user",
githubOwnerId,
};
}
const githubOwnerId = parseGitHubNumericId(
publisher.githubOrgId,
"Connect a verified GitHub organization to this publisher",
);
if (!publisher.githubVerifiedAt) {
throw new ConvexError("Connect a verified GitHub organization to this publisher");
}
const membership = await ctx.db
.query("githubOrgMemberships")
.withIndex("by_user_and_github_org", (q) =>
q.eq("userId", args.userId).eq("githubOrgId", githubOwnerId),
)
.unique();
const now = args.now ?? Date.now();
if (
!membership ||
membership.role !== "admin" ||
now - membership.syncedAt > GITHUB_ORG_MEMBERSHIP_VERIFICATION_MAX_AGE_MS
) {
throw new ConvexError("Reconnect GitHub to verify current organization admin access");
}
return {
publisherId: publisher._id,
publisherHandle: publisher.handle,
publisherKind: "org",
githubOwnerId,
githubLogin: membership.login,
};
}
export const getGitHubSkillSyncPublisherContextInternal = internalQuery({
args: {
publisherId: v.id("publishers"),
userId: v.id("users"),
},
handler: getGitHubSkillSyncPublisherContextHandler,
});
async function resolvePreviewDestination(
ctx: QueryCtx,
publisher: Doc<"publishers">,
source: Doc<"githubSkillSources"> | null,
discovered: GitHubSkillSyncDiscoveredSkill,
): Promise<GitHubSkillSyncPreviewDestination> {
if (source?.ownerPublisherId && source.ownerPublisherId !== publisher._id) {
const owner = await ctx.db.get(source.ownerPublisherId);
return {
kind: "source-conflict",
ownerPublisherId: source.ownerPublisherId,
ownerHandle: owner?.handle ?? "another publisher",
};
}
const [skill, alias] = await Promise.all([
getSkillBySlugForPublisher(ctx, discovered.slug, publisher),
getSkillSlugAliasBySlugForPublisher(ctx, discovered.slug, publisher),
]);
if (alias && (!skill || alias.skillId !== skill._id)) {
const aliasedSkill = await ctx.db.get(alias.skillId);
return {
kind: "alias-conflict",
skillId: alias.skillId,
ownerPublisherId: publisher._id,
ownerHandle: publisher.handle,
slug: discovered.slug,
displayName: aliasedSkill?.displayName ?? discovered.displayName,
};
}
if (!skill) return { kind: "none" };
let unavailableReason:
| "destination-soft-deleted"
| "already-synced"
| "destination-uses-another-github-source"
| undefined;
if (skill.softDeletedAt) {
unavailableReason = "destination-soft-deleted";
} else if (skill.installKind === "github") {
unavailableReason =
source && skill.githubSourceId === source._id && skill.githubPath === discovered.path
? "already-synced"
: "destination-uses-another-github-source";
}
return {
kind: "owned",
skillId: skill._id,
ownerPublisherId: publisher._id,
ownerHandle: publisher.handle,
slug: skill.slug,
displayName: skill.displayName,
installKind: skill.installKind === "github" ? "github" : "hosted",
...(unavailableReason ? { unavailableReason } : {}),
};
}
export async function classifyGitHubSkillSyncRepositoryHandler(
ctx: QueryCtx,
args: {
publisherId: Id<"publishers">;
repo: string;
skills: GitHubSkillSyncDiscoveredSkill[];
},
): Promise<GitHubSkillSyncPreviewItem[]> {
const publisher = await ctx.db.get(args.publisherId);
if (!publisher || publisher.deletedAt || publisher.deactivatedAt) {
throw new ConvexError("Publisher not found");
}
const source = await ctx.db
.query("githubSkillSources")
.withIndex("by_repo", (q) => q.eq("repo", args.repo))
.unique();
return await Promise.all(
args.skills.map(async (discovered) => {
let invalidSlug = false;
try {
assertValidSkillSlug(discovered.slug);
} catch {
invalidSlug = true;
}
return classifyGitHubSkillSyncPreviewItem({
discovered,
destination: await resolvePreviewDestination(ctx, publisher, source, discovered),
invalidSlug,
});
}),
);
}
export const classifyGitHubSkillSyncRepositoryInternal = internalQuery({
args: {
publisherId: v.id("publishers"),
repo: v.string(),
skills: v.array(discoveredSkillValidator),
},
handler: classifyGitHubSkillSyncRepositoryHandler,
});
async function requireActionPublisherContext(
ctx: Pick<ActionCtx, "runQuery">,
publisherId: Id<"publishers">,
authOverride?: { userId: Id<"users"> },
): Promise<PublisherContext> {
const actor = authOverride ?? (await requireUserFromAction(ctx as ActionCtx));
return (await ctx.runQuery(
internal.githubSkillSyncSettings.getGitHubSkillSyncPublisherContextInternal,
{
publisherId,
userId: actor.userId,
},
)) as PublisherContext;
}
async function buildGitHubSettingsHeaders(fetcher: typeof fetch) {
return await buildGitHubApiHeaders({
userAgent: "clawhub/github-skill-sync-settings",
fetchImpl: fetcher,
});
}
async function fetchVerifiedOwnerLogin(context: PublisherContext, fetcher: typeof fetch) {
const endpoint =
context.publisherKind === "org"
? `${GITHUB_API}/organizations/${context.githubOwnerId}`
: `${GITHUB_API}/user/${context.githubOwnerId}`;
const response = await fetcher(endpoint, {
headers: await buildGitHubSettingsHeaders(fetcher),
});
if (!response.ok) throw new ConvexError("GitHub account lookup failed");
const body = (await response.json()) as Record<string, unknown>;
const id = parseGitHubNumericId(body.id, "GitHub account lookup failed");
const login = typeof body.login === "string" ? body.login.trim() : "";
if (id !== context.githubOwnerId || !login) {
throw new ConvexError("GitHub account lookup failed");
}
return login;
}
function parseRepositoryMetadata(
value: unknown,
expectedOwnerId?: string,
): GitHubRepositoryMetadata | null {
if (!value || typeof value !== "object") return null;
const row = value as Record<string, unknown>;
const owner =
row.owner && typeof row.owner === "object" ? (row.owner as Record<string, unknown>) : null;
const repo = typeof row.full_name === "string" ? row.full_name.trim() : "";
const repositoryId =
typeof row.id === "number" && Number.isSafeInteger(row.id) && row.id > 0
? String(row.id)
: typeof row.id === "string" && /^[1-9]\d*$/.test(row.id.trim())
? row.id.trim()
: "";
const ownerLogin = typeof owner?.login === "string" ? owner.login.trim() : "";
const ownerId =
typeof owner?.id === "number" && Number.isSafeInteger(owner.id) && owner.id > 0
? String(owner.id)
: typeof owner?.id === "string" && /^[1-9]\d*$/.test(owner.id.trim())
? owner.id.trim()
: "";
if (
!repositoryId ||
!repo ||
!ownerLogin ||
!ownerId ||
(expectedOwnerId && ownerId !== expectedOwnerId) ||
row.private !== false ||
(typeof row.visibility === "string" && row.visibility !== "public")
) {
return null;
}
return {
repositoryId,
repo,
ownerId,
ownerLogin,
defaultBranch:
typeof row.default_branch === "string" && row.default_branch.trim()
? row.default_branch.trim()
: "main",
archived: row.archived === true,
disabled: row.disabled === true,
fork: row.fork === true,
};
}
function toRepositoryListItem(
value: unknown,
expectedOwnerId: string,
): GitHubRepositoryListItem | null {
const metadata = parseRepositoryMetadata(value, expectedOwnerId);
if (!metadata) return null;
const row = value as Record<string, unknown>;
const unavailableReason = metadata.disabled ? "disabled" : null;
return {
...metadata,
pushedAt: typeof row.pushed_at === "string" ? row.pushed_at : null,
selectable: unavailableReason === null,
unavailableReason,
};
}
export async function listGitHubSkillSyncRepositoriesHandler(
ctx: Pick<ActionCtx, "runQuery">,
args: {
publisherId: Id<"publishers">;
page?: number;
perPage?: number;
},
fetcher: typeof fetch = fetch,
authOverride?: { userId: Id<"users"> },
): Promise<GitHubSkillSyncRepositoryListResult> {
assertGitHubSkillSyncRuntimeEnabled();
const context = await requireActionPublisherContext(ctx, args.publisherId, authOverride);
const login = context.githubLogin ?? (await fetchVerifiedOwnerLogin(context, fetcher));
const page = clampInteger(args.page ?? 1, 1, 100);
const perPage = clampInteger(
args.perPage ?? DEFAULT_REPOSITORY_PAGE_SIZE,
1,
MAX_REPOSITORY_PAGE_SIZE,
);
const endpoint =
context.publisherKind === "org"
? `${GITHUB_API}/orgs/${encodeURIComponent(login)}/repos`
: `${GITHUB_API}/users/${encodeURIComponent(login)}/repos`;
const url = new URL(endpoint);
url.searchParams.set("type", context.publisherKind === "org" ? "all" : "owner");
url.searchParams.set("sort", "pushed");
url.searchParams.set("direction", "desc");
url.searchParams.set("page", String(page));
url.searchParams.set("per_page", String(perPage));
const response = await fetcher(url, {
headers: await buildGitHubSettingsHeaders(fetcher),
});
if (!response.ok) throw new ConvexError("GitHub repository lookup failed");
const body = (await response.json()) as unknown;
if (!Array.isArray(body)) throw new ConvexError("GitHub repository lookup failed");
const repositories = body
.map((repo) => toRepositoryListItem(repo, context.githubOwnerId))
.filter((repo): repo is GitHubRepositoryListItem => repo !== null);
return {
publisher: {
_id: context.publisherId,
handle: context.publisherHandle,
kind: context.publisherKind,
},
page,
perPage,
hasMore: body.length === perPage,
repositories,
};
}
export const listRepositories: ReturnType<typeof action> = action({
args: {
publisherId: v.id("publishers"),
page: v.optional(v.number()),
perPage: v.optional(v.number()),
},
handler: async (ctx, args): Promise<GitHubSkillSyncRepositoryListResult> =>
listGitHubSkillSyncRepositoriesHandler(ctx, args),
});
async function fetchVerifiedRepositoryMetadata(
repo: string,
expectedOwnerId: string,
fetcher: typeof fetch,
) {
const normalizedRepo = normalizeRepo(repo);
const [owner, name] = normalizedRepo.split("/") as [string, string];
const response = await fetcher(
`${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`,
{
headers: await buildGitHubSettingsHeaders(fetcher),
},
);
if (!response.ok) {
if (response.status === 404) throw new ConvexError("Enter a public GitHub repo.");
throw new ConvexError("GitHub repo lookup failed.");
}
const metadata = parseRepositoryMetadata(await response.json(), expectedOwnerId);
if (!metadata) {
throw new ConvexError("Repository ownership does not match the selected publisher.");
}
if (metadata.disabled) throw new ConvexError("GitHub repo is disabled.");
return { requestedRepo: normalizedRepo, ...metadata };
}
export async function previewGitHubSkillSyncRepositoryHandler(
ctx: Pick<ActionCtx, "runQuery">,
args: {
publisherId: Id<"publishers">;
repo: string;
},
fetcher: typeof fetch = fetch,
authOverride?: { userId: Id<"users"> },
): Promise<GitHubSkillSyncRepositoryPreviewResult> {
assertGitHubSkillSyncRuntimeEnabled();
const context = await requireActionPublisherContext(ctx, args.publisherId, authOverride);
const metadata = await fetchVerifiedRepositoryMetadata(args.repo, context.githubOwnerId, fetcher);
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.");
}
const discovered = snapshot.skills.map(({ slug, displayName, path, contentHash }) => ({
slug,
displayName,
path,
contentHash,
}));
const items = (await ctx.runQuery(
internal.githubSkillSyncSettings.classifyGitHubSkillSyncRepositoryInternal,
{
publisherId: context.publisherId,
repo: metadata.repo,
skills: discovered,
},
)) as GitHubSkillSyncPreviewItem[];
const count = (classification: GitHubSkillSyncPreviewItem["classification"]) =>
items.filter((item) => item.classification === classification).length;
return {
publisher: {
_id: context.publisherId,
handle: context.publisherHandle,
kind: context.publisherKind,
},
repository: {
requestedRepo: metadata.requestedRepo,
repositoryId: metadata.repositoryId,
repo: metadata.repo,
redirected: metadata.requestedRepo.toLowerCase() !== metadata.repo.toLowerCase(),
defaultBranch: metadata.defaultBranch,
commit: snapshot.commit,
},
summary: {
total: items.length,
newDestinations: count("new-destination"),
replacements: count("replacement"),
unavailable: count("unavailable"),
conflicts: count("ownership-conflict"),
},
items,
};
}
export const previewRepository: ReturnType<typeof action> = action({
args: {
publisherId: v.id("publishers"),
repo: v.string(),
},
handler: async (ctx, args): Promise<GitHubSkillSyncRepositoryPreviewResult> =>
previewGitHubSkillSyncRepositoryHandler(ctx, args),
});
function normalizeRepo(value: string) {
const trimmed = value
.trim()
.replace(/^https?:\/\/(?:www\.)?github\.com\//i, "")
.replace(/^github\.com\//i, "")
.replace(/\.git$/i, "")
.split(/[?#]/)[0];
const parts = trimmed.split("/").filter(Boolean);
if (parts.length !== 2) throw new ConvexError("GitHub repo must be owner/repo");
return `${parts[0]}/${parts[1]}`;
}
function clampInteger(value: number, min: number, max: number) {
const finite = Number.isFinite(value) ? Math.trunc(value) : min;
return Math.min(max, Math.max(min, finite));
}
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { classifyGitHubSkillSyncPreviewItem } from "./githubSkillSyncSettings";
const discovered = {
slug: "html",
displayName: "HTML",
path: "skills/html",
contentHash: "content-hash",
};
describe("classifyGitHubSkillSyncPreviewItem", () => {
it("classifies a repository skill with no destination as a new destination", () => {
expect(
classifyGitHubSkillSyncPreviewItem({
discovered,
destination: { kind: "none" },
}),
).toMatchObject({
classification: "new-destination",
eligible: true,
destination: null,
});
});
it("classifies a controlled Hosted Skill as a replacement", () => {
expect(
classifyGitHubSkillSyncPreviewItem({
discovered,
destination: {
kind: "owned",
skillId: "skills:html",
ownerPublisherId: "publishers:patrick",
ownerHandle: "patrick",
slug: "html",
displayName: "HTML",
installKind: "hosted",
},
}),
).toMatchObject({
classification: "replacement",
eligible: true,
destination: {
skillId: "skills:html",
ownerHandle: "patrick",
},
});
});
it("classifies unavailable and conflicting destinations without allowing activation", () => {
expect(
classifyGitHubSkillSyncPreviewItem({
discovered: { ...discovered, slug: "missing" },
destination: {
kind: "owned",
skillId: "skills:missing",
ownerPublisherId: "publishers:patrick",
ownerHandle: "patrick",
slug: "missing",
displayName: "Missing",
installKind: "hosted",
unavailableReason: "destination-soft-deleted",
},
}),
).toMatchObject({
classification: "unavailable",
eligible: false,
reason: "destination-soft-deleted",
});
expect(
classifyGitHubSkillSyncPreviewItem({
discovered: { ...discovered, slug: "claimed" },
destination: {
kind: "source-conflict",
ownerPublisherId: "publishers:other",
ownerHandle: "other",
},
}),
).toMatchObject({
classification: "ownership-conflict",
eligible: false,
reason: "repository-owned-by-another-publisher",
});
});
});
+139
View File
@@ -0,0 +1,139 @@
export type GitHubSkillSyncPreviewClassification =
| "new-destination"
| "replacement"
| "unavailable"
| "ownership-conflict";
export type GitHubSkillSyncDiscoveredSkill = {
slug: string;
displayName: string;
path: string;
contentHash: string;
};
export type GitHubSkillSyncPreviewDestination =
| { kind: "none" }
| {
kind: "owned";
skillId: string;
ownerPublisherId: string;
ownerHandle: string;
slug: string;
displayName: string;
installKind: "hosted" | "github";
unavailableReason?:
| "destination-soft-deleted"
| "already-synced"
| "destination-uses-another-github-source";
}
| {
kind: "alias-conflict";
skillId: string;
ownerPublisherId: string;
ownerHandle: string;
slug: string;
displayName: string;
}
| {
kind: "source-conflict";
ownerPublisherId: string;
ownerHandle: string;
};
export type GitHubSkillSyncPreviewItem = GitHubSkillSyncDiscoveredSkill & {
classification: GitHubSkillSyncPreviewClassification;
eligible: boolean;
reason?:
| "invalid-skill-slug"
| "destination-soft-deleted"
| "already-synced"
| "destination-uses-another-github-source"
| "destination-alias-conflict"
| "repository-owned-by-another-publisher";
destination: {
skillId: string;
ownerPublisherId: string;
ownerHandle: string;
slug: string;
displayName: string;
} | null;
};
function toDestination(
destination: Extract<GitHubSkillSyncPreviewDestination, { kind: "owned" | "alias-conflict" }>,
) {
return {
skillId: destination.skillId,
ownerPublisherId: destination.ownerPublisherId,
ownerHandle: destination.ownerHandle,
slug: destination.slug,
displayName: destination.displayName,
};
}
export function classifyGitHubSkillSyncPreviewItem({
discovered,
destination,
invalidSlug = false,
}: {
discovered: GitHubSkillSyncDiscoveredSkill;
destination: GitHubSkillSyncPreviewDestination;
invalidSlug?: boolean;
}): GitHubSkillSyncPreviewItem {
if (invalidSlug) {
return {
...discovered,
classification: "unavailable",
eligible: false,
reason: "invalid-skill-slug",
destination:
destination.kind === "owned" || destination.kind === "alias-conflict"
? toDestination(destination)
: null,
};
}
if (destination.kind === "source-conflict") {
return {
...discovered,
classification: "ownership-conflict",
eligible: false,
reason: "repository-owned-by-another-publisher",
destination: null,
};
}
if (destination.kind === "none") {
return {
...discovered,
classification: "new-destination",
eligible: true,
destination: null,
};
}
if (destination.kind === "alias-conflict") {
return {
...discovered,
classification: "ownership-conflict",
eligible: false,
reason: "destination-alias-conflict",
destination: toDestination(destination),
};
}
if (destination.unavailableReason) {
return {
...discovered,
classification:
destination.unavailableReason === "destination-uses-another-github-source"
? "ownership-conflict"
: "unavailable",
eligible: false,
reason: destination.unavailableReason,
destination: toDestination(destination),
};
}
return {
...discovered,
classification: "replacement",
eligible: true,
destination: toDestination(destination),
};
}
@@ -0,0 +1,331 @@
import { AlertTriangle, Check, GitBranch } from "lucide-react";
import type { FormEvent, ReactNode } from "react";
import type { Id } from "../../convex/_generated/dataModel";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
export type GitHubSkillSyncPublisherOption = {
publisher: {
_id: Id<"publishers">;
handle: string;
};
};
export type GitHubSkillSyncRepository = {
repositoryId: string;
repo: string;
ownerId: string;
ownerLogin: string;
defaultBranch: string;
archived: boolean;
disabled: boolean;
fork: boolean;
pushedAt: string | null;
selectable: boolean;
unavailableReason: "disabled" | null;
};
export type GitHubSkillSyncPreviewItem = {
slug: string;
displayName: string;
path: string;
contentHash: string;
classification: "new-destination" | "replacement" | "unavailable" | "ownership-conflict";
eligible: boolean;
reason?: string;
destination: {
skillId: Id<"skills">;
ownerPublisherId: Id<"publishers">;
ownerHandle: string;
slug: string;
displayName: string;
} | null;
};
export type GitHubSkillSyncPreview = {
publisher: {
_id: Id<"publishers">;
handle: string;
kind: "user" | "org";
};
repository: {
requestedRepo: string;
repositoryId: string;
repo: string;
redirected: boolean;
defaultBranch: string;
commit: string;
};
summary: {
total: number;
newDestinations: number;
replacements: number;
unavailable: number;
conflicts: number;
};
items: GitHubSkillSyncPreviewItem[];
};
export function GitHubSkillSyncConfiguration({
publisherOptions,
selectedPublisherId,
onPublisherChange,
repositories,
repositoriesError,
isLoadingRepositories,
githubRepo,
onGithubRepoChange,
onPreview,
isPreviewing,
preview,
}: {
publisherOptions: GitHubSkillSyncPublisherOption[];
selectedPublisherId: string;
onPublisherChange: (publisherId: string) => void;
repositories: GitHubSkillSyncRepository[];
repositoriesError: string | null;
isLoadingRepositories: boolean;
githubRepo: string;
onGithubRepoChange: (repo: string) => void;
onPreview: (event: FormEvent) => void;
isPreviewing: boolean;
preview: GitHubSkillSyncPreview | null;
}) {
return (
<div className="flex flex-col gap-5">
<form className="flex flex-col gap-4" onSubmit={onPreview}>
<Field label="Publisher" htmlFor="settings-github-source-publisher">
<Select value={selectedPublisherId} onValueChange={onPublisherChange}>
<SelectTrigger id="settings-github-source-publisher">
<SelectValue placeholder="Select publisher" />
</SelectTrigger>
<SelectContent>
{publisherOptions.map((entry) => (
<SelectItem key={entry.publisher._id} value={entry.publisher._id}>
@{entry.publisher.handle}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<div className="flex flex-col gap-2">
<Label>Verified repositories</Label>
{isLoadingRepositories ? (
<p className="text-sm text-[color:var(--ink-soft)]">Loading repositories...</p>
) : repositories.length ? (
<div className="divide-y divide-[color:var(--line)] border-y border-[color:var(--line)]">
{repositories.map((repository) => {
const selected = githubRepo.toLowerCase() === repository.repo.toLowerCase();
return (
<button
key={repository.repositoryId}
type="button"
aria-label={`Select ${repository.repo}`}
disabled={!repository.selectable}
onClick={() => onGithubRepoChange(repository.repo)}
className={`flex min-h-12 w-full min-w-0 items-center justify-between gap-3 px-1 py-3 text-left ${
selected
? "text-[color:var(--ink)]"
: "text-[color:var(--ink-soft)] hover:text-[color:var(--ink)]"
} disabled:cursor-not-allowed disabled:opacity-50`}
>
<span className="flex min-w-0 items-center gap-3">
<GitBranch size={16} className="shrink-0" />
<span className="truncate text-sm font-semibold">{repository.repo}</span>
</span>
{selected ? (
<Check size={16} className="shrink-0 text-[color:var(--accent)]" />
) : null}
</button>
);
})}
</div>
) : (
<p className="text-sm text-[color:var(--ink-soft)]">
No public repositories were returned for this publisher.
</p>
)}
{repositoriesError ? (
<p className="text-sm font-medium text-status-error-fg" role="alert">
{repositoriesError}
</p>
) : null}
</div>
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1">
<Field label="Repository URL" htmlFor="settings-github-repo">
<Input
id="settings-github-repo"
value={githubRepo}
onChange={(event) => onGithubRepoChange(event.target.value)}
placeholder="https://github.com/owner/repo"
/>
</Field>
</div>
<Button type="submit" disabled={!githubRepo.trim() || isPreviewing} className="shrink-0">
<GitBranch size={16} />
{isPreviewing ? "Previewing..." : "Preview repository"}
</Button>
</div>
</form>
{preview ? <GitHubSkillSyncRepositoryPreview preview={preview} /> : null}
</div>
);
}
function GitHubSkillSyncRepositoryPreview({ preview }: { preview: GitHubSkillSyncPreview }) {
return (
<section
className="flex flex-col gap-4 border-t border-[color:var(--line)] pt-5"
aria-labelledby="github-skill-sync-preview-title"
>
<div className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<h4
id="github-skill-sync-preview-title"
className="text-sm font-bold text-[color:var(--ink)]"
>
Repository preview
</h4>
<p className="truncate text-sm text-[color:var(--ink-soft)]">
{preview.repository.repo} at {preview.repository.commit.slice(0, 7)}
</p>
</div>
<span className="text-xs font-semibold text-[color:var(--ink-soft)]">
{preview.summary.total} {preview.summary.total === 1 ? "skill" : "skills"}
</span>
</div>
<div className="grid grid-cols-2 gap-x-5 gap-y-3 border-y border-[color:var(--line)] py-3 sm:grid-cols-4">
<SummaryCount label="New" value={preview.summary.newDestinations} />
<SummaryCount label="Replacements" value={preview.summary.replacements} />
<SummaryCount label="Unavailable" value={preview.summary.unavailable} />
<SummaryCount label="Conflicts" value={preview.summary.conflicts} />
</div>
{preview.summary.replacements > 0 ? (
<div className="flex items-start gap-3 border-l-2 border-status-warning-fg bg-status-warning-bg px-3 py-3">
<AlertTriangle size={17} className="mt-0.5 shrink-0 text-status-warning-fg" />
<p className="text-sm leading-6 text-[color:var(--ink)]">
Matching Hosted Skills switch to GitHub Skill Sync only after their exact candidates
pass ClawHub scanning.
</p>
</div>
) : null}
<div className="divide-y divide-[color:var(--line)] border-y border-[color:var(--line)]">
{preview.items.map((item) => (
<div
key={`${item.path}:${item.contentHash}`}
className="flex min-w-0 flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[color:var(--ink)]">
{item.displayName}
</p>
<p className="truncate text-xs font-mono text-[color:var(--ink-soft)]">{item.path}</p>
</div>
<span
className={`shrink-0 text-xs font-semibold ${classificationTone(
item.classification,
)}`}
>
{classificationLabel(item.classification)}
</span>
{item.reason ? (
<p className="text-xs leading-5 text-[color:var(--ink-soft)] sm:max-w-72 sm:text-right">
{previewReasonLabel(item.reason)}
</p>
) : null}
</div>
))}
</div>
<div className="flex justify-end">
<Button
type="button"
variant="primary"
disabled
title="Activation waits for the canonical GitHub Skill Sync engine."
>
Enable GitHub Skill Sync
</Button>
</div>
</section>
);
}
function Field({
label,
htmlFor,
children,
}: {
label: string;
htmlFor: string;
children: ReactNode;
}) {
return (
<div className="flex min-w-0 flex-col gap-2">
<Label htmlFor={htmlFor}>{label}</Label>
{children}
</div>
);
}
function SummaryCount({ label, value }: { label: string; value: number }) {
return (
<div className="min-w-0">
<div className="text-lg font-bold text-[color:var(--ink)]">{value}</div>
<div className="text-xs font-semibold text-[color:var(--ink-soft)]">{label}</div>
</div>
);
}
function classificationLabel(classification: GitHubSkillSyncPreviewItem["classification"]) {
switch (classification) {
case "new-destination":
return "New destination";
case "replacement":
return "Hosted Skill replacement";
case "unavailable":
return "Unavailable";
case "ownership-conflict":
return "Ownership conflict";
}
}
function classificationTone(classification: GitHubSkillSyncPreviewItem["classification"]) {
switch (classification) {
case "new-destination":
return "text-status-success-fg";
case "replacement":
return "text-status-warning-fg";
case "unavailable":
case "ownership-conflict":
return "text-status-error-fg";
}
}
function previewReasonLabel(reason: string) {
switch (reason) {
case "invalid-skill-slug":
return "The discovered skill slug is not valid.";
case "destination-soft-deleted":
return "A deleted destination already uses this slug.";
case "already-synced":
return "This skill is already synchronized from this repository.";
case "destination-uses-another-github-source":
return "This destination is connected to another GitHub repository.";
case "destination-alias-conflict":
return "This slug is reserved by an existing publisher alias.";
case "repository-owned-by-another-publisher":
return "This repository is already connected to another publisher.";
default:
return "This skill is not eligible for GitHub Skill Sync.";
}
}
+134 -20
View File
@@ -246,7 +246,10 @@ function mockSignedInSettings({
if (queryName === "publishers:listMembers") return members;
if (queryName === "publishers:listInvitesForPublisher") return pendingInvites;
if (queryName === "publishers:listMyInvites") return myInvites;
if (queryName === "githubSkillSources:listForManageableOfficialPublishers")
if (
queryName === "githubSkillSources:listForManageableOfficialPublishers" ||
queryName === "githubSkillSources:listForPublisher"
)
return githubSources;
if (args && typeof args === "object" && "publisherHandle" in args) return members;
if (args && typeof args === "object") return [];
@@ -275,7 +278,14 @@ describe("Settings", () => {
searchMock.mockReset();
searchMock.mockReturnValue({});
useMutationMock.mockReturnValue(vi.fn());
useActionMock.mockReturnValue(vi.fn());
const defaultListRepositories = vi.fn().mockResolvedValue({ repositories: [] });
const defaultAction = vi.fn();
useActionMock.mockImplementation((action) => {
if (getFunctionName(action) === "githubSkillSyncSettings:listRepositories") {
return defaultListRepositories;
}
return defaultAction;
});
vi.mocked(toast.error).mockReset();
vi.mocked(toast.success).mockReset();
useAuthActionsMock.mockReturnValue({
@@ -653,12 +663,101 @@ describe("Settings", () => {
expect(navigateMock).toHaveBeenCalledWith({ to: "/", replace: true });
});
it("lets official publisher owners configure a public GitHub sync source", async () => {
const configureSource = vi.fn().mockResolvedValue({ ok: true, stats: { discovered: 1 } });
useActionMock.mockReturnValue(configureSource);
it("lets verified publishers select a repository and preview direct repository skills", async () => {
const listRepositories = vi.fn().mockResolvedValue({
publisher: { _id: "publisher_patrick", handle: "patrick", kind: "user" },
page: 1,
perPage: 100,
hasMore: false,
repositories: [
{
repositoryId: "1",
repo: "patrick-erichsen/skills",
ownerId: "123",
ownerLogin: "patrick-erichsen",
defaultBranch: "main",
archived: false,
disabled: false,
fork: false,
pushedAt: "2026-07-23T12:00:00Z",
selectable: true,
unavailableReason: null,
},
],
});
const previewRepository = vi.fn().mockResolvedValue({
publisher: { _id: "publisher_patrick", handle: "patrick", kind: "user" },
repository: {
requestedRepo: "patrick-erichsen/skills",
repositoryId: "1",
repo: "patrick-erichsen/skills",
redirected: false,
defaultBranch: "main",
commit: "a".repeat(40),
},
summary: {
total: 4,
newDestinations: 1,
replacements: 1,
unavailable: 1,
conflicts: 1,
},
items: [
{
slug: "new-skill",
displayName: "New Skill",
path: "skills/new-skill",
contentHash: "hash-new",
classification: "new-destination",
eligible: true,
destination: null,
},
{
slug: "html",
displayName: "HTML",
path: "skills/html",
contentHash: "hash-html",
classification: "replacement",
eligible: true,
destination: {
skillId: "skills:html",
ownerPublisherId: "publisher_patrick",
ownerHandle: "patrick",
slug: "html",
displayName: "HTML",
},
},
{
slug: "old-skill",
displayName: "Old Skill",
path: "skills/old-skill",
contentHash: "hash-old",
classification: "unavailable",
eligible: false,
reason: "destination-soft-deleted",
destination: null,
},
{
slug: "claimed-skill",
displayName: "Claimed Skill",
path: "skills/claimed-skill",
contentHash: "hash-claimed",
classification: "ownership-conflict",
eligible: false,
reason: "repository-owned-by-another-publisher",
destination: null,
},
],
});
useActionMock.mockImplementation((action) => {
const actionName = getFunctionName(action);
if (actionName === "githubSkillSyncSettings:listRepositories") return listRepositories;
if (actionName === "githubSkillSyncSettings:previewRepository") return previewRepository;
return vi.fn();
});
mockSignedInSettings({
search: { view: "githubSources" },
memberships: [personalMembership, orgMembership],
memberships: [personalMembership],
});
render(<Settings />);
@@ -666,26 +765,40 @@ describe("Settings", () => {
expect(
screen.getByRole("button", { name: "GitHub Skill Sync" }).getAttribute("aria-current"),
).toBe("true");
expect(screen.getByRole("heading", { name: "Sync GitHub skills repo" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Configure GitHub Skill Sync" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "Synced repositories" })).toBeTruthy();
expect(screen.getByRole("heading", { name: "No synced repositories" })).toBeTruthy();
expect(screen.getByLabelText("Publisher")).toBeTruthy();
expect(screen.getByPlaceholderText("https://github.com/owner/repo")).toBeTruthy();
expect(screen.queryByText(/Publishing as/i)).toBeNull();
expect(screen.queryByText(/skills\.sh\.json/i)).toBeNull();
await waitFor(() => expect(listRepositories).toHaveBeenCalled());
expect(await screen.findByText("patrick-erichsen/skills")).toBeTruthy();
fireEvent.change(screen.getByLabelText("GitHub repo URL"), {
target: { value: "https://github.com/NVIDIA/skills" },
});
fireEvent.click(screen.getByRole("button", { name: /Add repo/i }));
fireEvent.click(screen.getByRole("button", { name: "Select patrick-erichsen/skills" }));
fireEvent.click(screen.getByRole("button", { name: "Preview repository" }));
await waitFor(() => {
expect(configureSource).toHaveBeenCalledWith({
ownerPublisherId: "publisher_openclaw",
repo: "NVIDIA/skills",
expect(previewRepository).toHaveBeenCalledWith({
publisherId: "publisher_patrick",
repo: "patrick-erichsen/skills",
});
});
expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/GitHub source synced/i));
expect(await screen.findByRole("heading", { name: "Repository preview" })).toBeTruthy();
expect(screen.getByText("New destination")).toBeTruthy();
expect(screen.getByText("Hosted Skill replacement")).toBeTruthy();
expect(screen.getAllByText("Unavailable").length).toBeGreaterThan(0);
expect(screen.getByText("Ownership conflict")).toBeTruthy();
expect(screen.getByText("A deleted destination already uses this slug.")).toBeTruthy();
expect(
screen.getByText("This repository is already connected to another publisher."),
).toBeTruthy();
expect(
screen.getAllByText(
/matching Hosted Skills switch to GitHub Skill Sync only after their exact candidates pass ClawHub scanning/i,
),
).toHaveLength(1);
expect(
screen.getByRole("button", { name: "Enable GitHub Skill Sync" }).hasAttribute("disabled"),
).toBe(true);
});
it("shows synced repos as separate cards and lets owners delete a source", async () => {
@@ -751,7 +864,7 @@ describe("Settings", () => {
expect(screen.getByRole("heading", { name: "mattpocock/skills" })).toBeTruthy();
expect(
screen.getByText(
"Add a public repo URL. ClawHub syncs metadata and scan results every 15 minutes. Users install your skills directly from your GitHub repo.",
"Select a verified public repository, inspect its destinations, then enable synchronization when the engine is available.",
),
).toBeTruthy();
const repoLink = screen.getByRole("link", { name: "https://github.com/mattpocock/skills" });
@@ -800,10 +913,11 @@ describe("Settings", () => {
expect(toast.success).toHaveBeenCalledWith("GitHub sync deleted (0 skills deleted)");
});
it("does not let non-official publishers access GitHub sync sources", () => {
it("keeps GitHub Skill Sync hidden when the backend capability is off", () => {
mockSignedInSettings({
search: { view: "githubSources" },
memberships: [personalMembership],
githubSkillSyncEnabled: false,
});
render(<Settings />);
+102 -120
View File
@@ -42,6 +42,11 @@ import { toast } from "sonner";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
import { EmptyState } from "../components/EmptyState";
import {
GitHubSkillSyncConfiguration,
type GitHubSkillSyncPreview,
type GitHubSkillSyncRepository,
} from "../components/GitHubSkillSyncConfiguration";
import { copyText } from "../components/InstallCopyButton";
import { MarketplaceIcon } from "../components/MarketplaceIcon";
import { SignInPrompt } from "../components/SignInPrompt";
@@ -300,7 +305,8 @@ export function Settings() {
const revokeMemberInvite = useMutation(api.publishers.revokeMemberInvite);
const acceptMemberInvite = useMutation(api.publishers.acceptMemberInvite);
const declineMemberInvite = useMutation(api.publishers.declineMemberInvite);
const configureGitHubSource = useAction(api.githubSkillSync.configurePublicGitHubSkillSource);
const listGitHubSyncRepositories = useAction(api.githubSkillSyncSettings.listRepositories);
const previewGitHubSyncRepository = useAction(api.githubSkillSyncSettings.previewRepository);
const deleteGitHubSource = useMutation(api.githubSkillSources.deleteForPublisher);
const [displayName, setDisplayName] = useState("");
const [bio, setBio] = useState("");
@@ -320,7 +326,11 @@ export function Settings() {
const [isUploadingOrgImage, setIsUploadingOrgImage] = useState(false);
const [selectedSourcePublisherId, setSelectedSourcePublisherId] = useState("");
const [githubRepo, setGithubRepo] = useState("");
const [isSyncingSource, setIsSyncingSource] = useState(false);
const [githubRepositories, setGitHubRepositories] = useState<GitHubSkillSyncRepository[]>([]);
const [githubRepositoriesError, setGitHubRepositoriesError] = useState<string | null>(null);
const [isLoadingGitHubRepositories, setIsLoadingGitHubRepositories] = useState(false);
const [githubSyncPreview, setGitHubSyncPreview] = useState<GitHubSkillSyncPreview | null>(null);
const [isPreviewingGitHubSource, setIsPreviewingGitHubSource] = useState(false);
const [deletingSourceId, setDeletingSourceId] = useState<Id<"githubSkillSources"> | null>(null);
const [sourceToDelete, setSourceToDelete] = useState<GitHubSkillSource | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -339,22 +349,18 @@ export function Settings() {
const manageablePublishers = (publisherMemberships ?? []).filter(
(entry) => entry.role !== "publisher",
);
const officialGitHubSourcePublishers = manageablePublishers.filter(
(entry) => entry.publisher.official === true,
);
const githubSourcePublishers = manageablePublishers;
const publisherMembershipsLoaded = publisherMemberships !== undefined;
const canConfigureGitHubSources =
rolloutCapabilities?.githubSkillSync.selfServiceEnabled === true &&
officialGitHubSourcePublishers.length > 0;
githubSourcePublishers.length > 0;
const effectiveActiveView =
activeView === "githubSources" && publisherMembershipsLoaded && !canConfigureGitHubSources
? "account"
: activeView;
const selectedSourcePublisher =
officialGitHubSourcePublishers.find(
(entry) => entry.publisher._id === selectedSourcePublisherId,
) ??
officialGitHubSourcePublishers[0] ??
githubSourcePublishers.find((entry) => entry.publisher._id === selectedSourcePublisherId) ??
githubSourcePublishers[0] ??
null;
const selectedOrg =
orgs.find((entry) => entry.publisher.handle === selectedOrgHandle) ?? orgs[0] ?? null;
@@ -407,11 +413,12 @@ export function Settings() {
shouldLoadAccountScopedQueries && activeView === "organizations" ? {} : "skip",
) as Array<PublisherInvite> | undefined;
const githubSources = useQuery(
api.githubSkillSources.listForManageableOfficialPublishers,
api.githubSkillSources.listForPublisher,
shouldLoadAccountScopedQueries &&
effectiveActiveView === "githubSources" &&
canConfigureGitHubSources
? {}
canConfigureGitHubSources &&
selectedSourcePublisher
? { ownerPublisherId: selectedSourcePublisher.publisher._id }
: "skip",
) as GitHubSkillSource[] | undefined;
const deletionInventory = useQuery(
@@ -437,14 +444,12 @@ export function Settings() {
}, [orgs, selectedOrgHandle]);
useEffect(() => {
if (!officialGitHubSourcePublishers.length) {
if (!githubSourcePublishers.length) {
setSelectedSourcePublisherId("");
return;
}
const requestedPublisher = requestedOwnerHandle
? officialGitHubSourcePublishers.find(
(entry) => entry.publisher.handle === requestedOwnerHandle,
)
? githubSourcePublishers.find((entry) => entry.publisher.handle === requestedOwnerHandle)
: null;
if (requestedPublisher) {
setSelectedSourcePublisherId(requestedPublisher.publisher._id);
@@ -452,14 +457,59 @@ export function Settings() {
}
if (
selectedSourcePublisherId &&
officialGitHubSourcePublishers.some(
(entry) => entry.publisher._id === selectedSourcePublisherId,
)
githubSourcePublishers.some((entry) => entry.publisher._id === selectedSourcePublisherId)
) {
return;
}
setSelectedSourcePublisherId(officialGitHubSourcePublishers[0]?.publisher._id ?? "");
}, [officialGitHubSourcePublishers, requestedOwnerHandle, selectedSourcePublisherId]);
setSelectedSourcePublisherId(githubSourcePublishers[0]?.publisher._id ?? "");
}, [githubSourcePublishers, requestedOwnerHandle, selectedSourcePublisherId]);
useEffect(() => {
if (
effectiveActiveView !== "githubSources" ||
!canConfigureGitHubSources ||
!selectedSourcePublisher
) {
setGitHubRepositories([]);
setGitHubRepositoriesError(null);
return;
}
let cancelled = false;
setIsLoadingGitHubRepositories(true);
setGitHubRepositoriesError(null);
setGitHubSyncPreview(null);
void listGitHubSyncRepositories({
publisherId: selectedSourcePublisher.publisher._id,
perPage: 100,
})
.then((result) => {
if (cancelled) return;
const repositories = result.repositories as GitHubSkillSyncRepository[];
setGitHubRepositories(repositories);
setGithubRepo((current) => {
if (repositories.some((repository) => repository.repo === current)) return current;
return repositories.find((repository) => repository.selectable)?.repo ?? "";
});
})
.catch((error) => {
if (cancelled) return;
setGitHubRepositories([]);
setGitHubRepositoriesError(
getUserFacingConvexError(error, "GitHub repositories could not be loaded."),
);
})
.finally(() => {
if (!cancelled) setIsLoadingGitHubRepositories(false);
});
return () => {
cancelled = true;
};
}, [
canConfigureGitHubSources,
effectiveActiveView,
listGitHubSyncRepositories,
selectedSourcePublisher,
]);
useEffect(() => {
if (!selectedOrg) {
@@ -733,23 +783,24 @@ export function Settings() {
}
}
async function onConfigureGitHubSource(event: FormEvent) {
async function onPreviewGitHubSource(event: FormEvent) {
event.preventDefault();
if (!selectedSourcePublisher) return;
const repo = parseGitHubRepoInput(githubRepo);
if (!repo) return;
setIsSyncingSource(true);
setIsPreviewingGitHubSource(true);
setGitHubSyncPreview(null);
try {
const result = await configureGitHubSource({
ownerPublisherId: selectedSourcePublisher.publisher._id,
const result = await previewGitHubSyncRepository({
publisherId: selectedSourcePublisher.publisher._id,
repo,
});
setGithubRepo("");
toast.success(formatGitHubSourceSyncToast(result?.stats));
setGithubRepo(result.repository.repo);
setGitHubSyncPreview(result as GitHubSkillSyncPreview);
} catch (error) {
toast.error(getUserFacingConvexError(error, "GitHub source could not be synced."));
toast.error(getUserFacingConvexError(error, "GitHub repository could not be previewed."));
} finally {
setIsSyncingSource(false);
setIsPreviewingGitHubSource(false);
}
}
@@ -1641,28 +1692,39 @@ export function Settings() {
</span>
<div className="min-w-0">
<h3 className="text-sm font-bold text-[color:var(--ink)]">
Sync GitHub skills repo
Configure GitHub Skill Sync
</h3>
<p className="text-sm text-[color:var(--ink-soft)]">
Add a public repo URL. ClawHub syncs metadata and scan results every 15
minutes. Users install your skills directly from your GitHub repo.
Select a verified public repository, inspect its destinations, then enable
synchronization when the engine is available.
</p>
</div>
</div>
{selectedSourcePublisher ? (
<GitHubSourceForm
publisherOptions={officialGitHubSourcePublishers}
<GitHubSkillSyncConfiguration
publisherOptions={githubSourcePublishers}
selectedPublisherId={selectedSourcePublisher.publisher._id}
onPublisherChange={setSelectedSourcePublisherId}
onPublisherChange={(publisherId) => {
setSelectedSourcePublisherId(publisherId);
setGithubRepo("");
setGitHubSyncPreview(null);
}}
repositories={githubRepositories}
repositoriesError={githubRepositoriesError}
isLoadingRepositories={isLoadingGitHubRepositories}
githubRepo={githubRepo}
onGithubRepoChange={setGithubRepo}
onConfigure={onConfigureGitHubSource}
isSyncing={isSyncingSource}
onGithubRepoChange={(repo) => {
setGithubRepo(repo);
setGitHubSyncPreview(null);
}}
onPreview={onPreviewGitHubSource}
isPreviewing={isPreviewingGitHubSource}
preview={githubSyncPreview}
/>
) : (
<p className="rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface-muted)]/25 p-3 text-sm text-[color:var(--ink-soft)]">
You need an official publisher profile before adding GitHub skill sync.
You need a publisher you manage before configuring GitHub Skill Sync.
</p>
)}
</div>
@@ -1902,31 +1964,6 @@ export function Settings() {
);
}
function formatGitHubSourceSyncToast(
stats:
| {
discovered?: number;
inserted?: number;
revived?: number;
conflicts?: number;
}
| undefined,
) {
const discovered = stats?.discovered ?? 0;
const inserted = stats?.inserted ?? 0;
const revived = stats?.revived ?? 0;
const conflicts = stats?.conflicts ?? 0;
const visibleChanges = inserted + revived;
const details = [
`${discovered} ${discovered === 1 ? "skill" : "skills"} found`,
visibleChanges > 0
? `${visibleChanges} ${visibleChanges === 1 ? "skill" : "skills"} added`
: null,
conflicts > 0 ? `${conflicts} conflict${conflicts === 1 ? "" : "s"}` : null,
].filter(Boolean);
return `GitHub source synced (${details.join(", ")})`;
}
function DeletionResourceSummary({
inventory,
emptyLabel,
@@ -2671,61 +2708,6 @@ function GitHubSourceStatusPill({ needsAttention }: { needsAttention: boolean })
);
}
function GitHubSourceForm({
publisherOptions,
selectedPublisherId,
onPublisherChange,
githubRepo,
onGithubRepoChange,
onConfigure,
isSyncing,
}: {
publisherOptions: PublisherMembership[];
selectedPublisherId: string;
onPublisherChange: (publisherId: string) => void;
githubRepo: string;
onGithubRepoChange: (repo: string) => void;
onConfigure: (event: FormEvent) => void;
isSyncing: boolean;
}) {
return (
<form className="flex flex-col gap-3 sm:flex-row sm:items-end" onSubmit={onConfigure}>
<div className="min-w-0 sm:w-64 sm:shrink-0">
<Field label="Publisher" htmlFor="settings-github-source-publisher">
<Select value={selectedPublisherId} onValueChange={onPublisherChange}>
<SelectTrigger id="settings-github-source-publisher">
<SelectValue placeholder="Select org" />
</SelectTrigger>
<SelectContent>
{publisherOptions.map((entry) => (
<SelectItem key={entry.publisher._id} value={entry.publisher._id}>
@{entry.publisher.handle}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1">
<Field label="GitHub repo URL" htmlFor="settings-github-repo">
<Input
id="settings-github-repo"
value={githubRepo}
onChange={(event) => onGithubRepoChange(event.target.value)}
placeholder="https://github.com/owner/repo"
/>
</Field>
</div>
<Button type="submit" disabled={!githubRepo.trim() || isSyncing} className="shrink-0">
<Plus size={16} />
{isSyncing ? "Adding..." : "Add repo"}
</Button>
</div>
</form>
);
}
function shortCommit(commit: string) {
return commit.slice(0, 7);
}