mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
feat: add GitHub Skill Sync configuration preview
This commit is contained in:
Vendored
+4
@@ -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;
|
||||
|
||||
@@ -1282,7 +1282,7 @@ export async function syncGitHubSkillSourcesHandler(
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchGitHubSkillSourceSnapshot(
|
||||
export async function fetchGitHubSkillSourceSnapshot(
|
||||
{
|
||||
repo,
|
||||
defaultBranch,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user