mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
feat: claim skills.sh listings through GitHub Skill Sync (#3230)
* feat: add verified mirrored skill adoption state * feat: add mirrored skill adoption preview * feat: route skills.sh claims through GitHub sync * ci: allow guarded CLAW-560 Test deploy * fix: canonicalize claimed GitHub source repos * fix: use public GitHub auth for skill sync * fix: authenticate public GitHub source reads
This commit is contained in:
@@ -37,7 +37,9 @@ jobs:
|
||||
(github.ref == 'refs/heads/pe/claw-577-canonical-mixed-search' &&
|
||||
inputs.branch_test_confirm == 'deploy-claw-577-to-permanent-test') ||
|
||||
(github.ref == 'refs/heads/pe/claw-583-mirrored-search-journey' &&
|
||||
inputs.branch_test_confirm == 'deploy-claw-583-to-permanent-test')) &&
|
||||
inputs.branch_test_confirm == 'deploy-claw-583-to-permanent-test') ||
|
||||
(github.ref == 'refs/heads/pe/claw-560-verified-adoption' &&
|
||||
inputs.branch_test_confirm == 'deploy-claw-560-to-permanent-test')) &&
|
||||
github.actor == 'Patrick-Erichsen' &&
|
||||
inputs.expected_sha != '')) ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
@@ -116,6 +118,14 @@ jobs:
|
||||
then
|
||||
branch_test_allowed=true
|
||||
fi
|
||||
if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]] &&
|
||||
[[ "$GITHUB_REF" == refs/heads/pe/claw-560-verified-adoption ]] &&
|
||||
[[ "$GITHUB_ACTOR" == Patrick-Erichsen ]] &&
|
||||
[[ "${{ inputs.branch_test_confirm }}" == deploy-claw-560-to-permanent-test ]] &&
|
||||
[[ "${{ inputs.expected_sha }}" == "$deploy_sha" ]]
|
||||
then
|
||||
branch_test_allowed=true
|
||||
fi
|
||||
if [[ "$GITHUB_EVENT_NAME" == pull_request ]] &&
|
||||
[[ "$GITHUB_HEAD_REF" == pe/claw-563-skills-sh-mirror-10k ]] &&
|
||||
[[ "$GITHUB_ACTOR" == Patrick-Erichsen ]] &&
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
import { getFunctionName } from "convex/server";
|
||||
import { ConvexError } from "convex/values";
|
||||
import { zipSync } from "fflate";
|
||||
@@ -282,17 +283,26 @@ describe("buildGitHubSourceImport", () => {
|
||||
});
|
||||
|
||||
describe("buildGitHubSkillSourceFetch", () => {
|
||||
it("attaches configured GitHub auth to API and archive requests only", async () => {
|
||||
it("uses public API auth without sending OAuth app credentials to codeload", async () => {
|
||||
const previousEnv = {
|
||||
token: process.env.GITHUB_TOKEN,
|
||||
appId: process.env.GITHUB_APP_ID,
|
||||
installationId: process.env.GITHUB_APP_INSTALLATION_ID,
|
||||
privateKey: process.env.GITHUB_APP_PRIVATE_KEY,
|
||||
oauthClientId: process.env.AUTH_GITHUB_ID,
|
||||
oauthClientSecret: process.env.AUTH_GITHUB_SECRET,
|
||||
};
|
||||
process.env.GITHUB_TOKEN = "github-token";
|
||||
delete process.env.GITHUB_APP_ID;
|
||||
delete process.env.GITHUB_APP_INSTALLATION_ID;
|
||||
delete process.env.GITHUB_APP_PRIVATE_KEY;
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
const { privateKey } = generateKeyPairSync("rsa", {
|
||||
modulusLength: 2048,
|
||||
privateKeyEncoding: { type: "pkcs1", format: "pem" },
|
||||
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||
});
|
||||
process.env.GITHUB_APP_ID = "3536245";
|
||||
process.env.GITHUB_APP_INSTALLATION_ID = "987654";
|
||||
process.env.GITHUB_APP_PRIVATE_KEY = privateKey;
|
||||
process.env.AUTH_GITHUB_ID = "oauth-client-id";
|
||||
process.env.AUTH_GITHUB_SECRET = "oauth-client-secret";
|
||||
const fetcher = vi.fn(async () => new Response("ok"));
|
||||
const wrapped = __test.buildGitHubSkillSourceFetch(fetcher as unknown as typeof fetch);
|
||||
|
||||
@@ -311,15 +321,22 @@ describe("buildGitHubSkillSourceFetch", () => {
|
||||
else process.env.GITHUB_APP_INSTALLATION_ID = previousEnv.installationId;
|
||||
if (previousEnv.privateKey === undefined) delete process.env.GITHUB_APP_PRIVATE_KEY;
|
||||
else process.env.GITHUB_APP_PRIVATE_KEY = previousEnv.privateKey;
|
||||
if (previousEnv.oauthClientId === undefined) delete process.env.AUTH_GITHUB_ID;
|
||||
else process.env.AUTH_GITHUB_ID = previousEnv.oauthClientId;
|
||||
if (previousEnv.oauthClientSecret === undefined) delete process.env.AUTH_GITHUB_SECRET;
|
||||
else process.env.AUTH_GITHUB_SECRET = previousEnv.oauthClientSecret;
|
||||
}
|
||||
|
||||
const calls = fetcher.mock.calls as unknown as Array<[RequestInfo | URL, RequestInit?]>;
|
||||
expect(calls).toHaveLength(3);
|
||||
const firstHeaders = calls[0]?.[1]?.headers as Headers;
|
||||
const secondHeaders = calls[1]?.[1]?.headers as Headers;
|
||||
const thirdInit = calls[2]?.[1];
|
||||
expect(firstHeaders.get("Authorization")).toBe("Bearer github-token");
|
||||
expect(firstHeaders.get("Authorization")).toBe(
|
||||
`Basic ${btoa("oauth-client-id:oauth-client-secret")}`,
|
||||
);
|
||||
expect(firstHeaders.get("Accept")).toBe("application/vnd.github+json");
|
||||
expect(secondHeaders.get("Authorization")).toBe("Bearer github-token");
|
||||
expect(secondHeaders.get("Authorization")).toBeNull();
|
||||
expect(secondHeaders.get("User-Agent")).toBe("clawhub/github-skill-source");
|
||||
expect(thirdInit).toBeUndefined();
|
||||
});
|
||||
@@ -348,7 +365,13 @@ describe("configurePublicGitHubSkillSourceHandler", () => {
|
||||
expect(runMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("configures any public GitHub repo for an official publisher the user can manage", async () => {
|
||||
it("uses Test OAuth app auth and stores a canonical lowercase repo identity", async () => {
|
||||
vi.stubEnv("GITHUB_TOKEN", "");
|
||||
vi.stubEnv("GITHUB_APP_ID", "");
|
||||
vi.stubEnv("GITHUB_APP_INSTALLATION_ID", "");
|
||||
vi.stubEnv("GITHUB_APP_PRIVATE_KEY", "");
|
||||
vi.stubEnv("AUTH_GITHUB_ID", "oauth-client-id");
|
||||
vi.stubEnv("AUTH_GITHUB_SECRET", "oauth-client-secret");
|
||||
const zip = zipSync({
|
||||
"skills-main/skills/aiq-deploy/SKILL.md": new TextEncoder().encode("# AIQ Deploy\n"),
|
||||
});
|
||||
@@ -413,10 +436,16 @@ describe("configurePublicGitHubSkillSourceHandler", () => {
|
||||
);
|
||||
|
||||
expect(result).toEqual({ ok: true, stats: { discovered: 1 } });
|
||||
const calls = fetchMock.mock.calls as unknown as Array<[RequestInfo | URL, RequestInit?]>;
|
||||
const expectedAuthorization = `Basic ${btoa("oauth-client-id:oauth-client-secret")}`;
|
||||
expect(new Headers(calls[0]?.[1]?.headers).get("Authorization")).toBe(expectedAuthorization);
|
||||
expect(new Headers(calls[1]?.[1]?.headers).get("Authorization")).toBe(expectedAuthorization);
|
||||
expect(new Headers(calls[2]?.[1]?.headers).get("Authorization")).toBeNull();
|
||||
expect(new Headers(calls[3]?.[1]?.headers).get("Authorization")).toBe(expectedAuthorization);
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
repo: "SomeoneElse/public-skills",
|
||||
repo: "someoneelse/public-skills",
|
||||
ownerUserId: "users:publisher-owner",
|
||||
ownerPublisherId: "publishers:local",
|
||||
snapshot: expect.objectContaining({
|
||||
@@ -434,6 +463,108 @@ describe("configurePublicGitHubSkillSourceHandler", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a changed skills.sh selection before applying repository writes", async () => {
|
||||
const zip = zipSync({
|
||||
"skills-main/skills/html/SKILL.md": new TextEncoder().encode("# HTML\n"),
|
||||
});
|
||||
const runQuery = vi.fn(async () => ({
|
||||
ownerUserId: "users:publisher-owner",
|
||||
existingSource: null,
|
||||
}));
|
||||
const runMutation = vi.fn();
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 101,
|
||||
full_name: "patrick-erichsen/skills",
|
||||
owner: { id: 201 },
|
||||
private: false,
|
||||
visibility: "public",
|
||||
default_branch: "main",
|
||||
disabled: false,
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ sha: "1".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),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
id: 101,
|
||||
full_name: "patrick-erichsen/skills",
|
||||
owner: { id: 201 },
|
||||
private: false,
|
||||
visibility: "public",
|
||||
default_branch: "main",
|
||||
disabled: false,
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
configurePublicGitHubSkillSourceHandler(
|
||||
{ runQuery, runMutation, auth: { getUserIdentity: vi.fn() } } as never,
|
||||
{
|
||||
ownerPublisherId: "publishers:local" as never,
|
||||
repo: "patrick-erichsen/skills",
|
||||
expectedSkillsShSource: {
|
||||
repo: "patrick-erichsen/skills",
|
||||
externalId: "patrick-erichsen/skills/html",
|
||||
path: "skills/html",
|
||||
commit: "2".repeat(40),
|
||||
contentHash: "3".repeat(64),
|
||||
},
|
||||
},
|
||||
fetchMock as never,
|
||||
{ userId: "users:actor" as never },
|
||||
),
|
||||
).rejects.toThrow(/changed since this skills\.sh listing was observed/i);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
expect(runMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches a skills.sh selection by exact slug, path, commit, and content hash", async () => {
|
||||
const snapshot = await buildGitHubSkillSourceSnapshot({
|
||||
repo: "patrick-erichsen/skills",
|
||||
defaultBranch: "main",
|
||||
commit: "1".repeat(40),
|
||||
entries: {
|
||||
"skills/html/SKILL.md": new TextEncoder().encode("---\nname: html\n---\n# HTML\n"),
|
||||
},
|
||||
});
|
||||
const contentHash = snapshot.skills[0]?.contentHash;
|
||||
if (!contentHash) throw new Error("missing fixture hash");
|
||||
const exact = {
|
||||
repo: "patrick-erichsen/skills",
|
||||
externalId: "patrick-erichsen/skills/html",
|
||||
path: "skills/html",
|
||||
commit: snapshot.commit,
|
||||
contentHash,
|
||||
};
|
||||
|
||||
expect(() => __test.assertExactSkillsShSourceSelection(snapshot, exact)).not.toThrow();
|
||||
for (const changed of [
|
||||
{ ...exact, repo: "openclaw/openclaw" },
|
||||
{ ...exact, externalId: "patrick-erichsen/skills/other" },
|
||||
{ ...exact, path: "skills/other" },
|
||||
{ ...exact, commit: "2".repeat(40) },
|
||||
{ ...exact, contentHash: "3".repeat(64) },
|
||||
]) {
|
||||
expect(() => __test.assertExactSkillsShSourceSelection(snapshot, changed)).toThrow(
|
||||
/changed since this skills\.sh listing was observed/i,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers nested catalog skill paths over duplicate plugin package copies", async () => {
|
||||
const zip = zipSync({
|
||||
"repo-main/plugins/aws-core/skills/amazon-bedrock/SKILL.md": new TextEncoder().encode(
|
||||
|
||||
@@ -139,6 +139,14 @@ type GitHubSkillSourceSetupContext = {
|
||||
existingSource: SourceForSync | null;
|
||||
};
|
||||
|
||||
type ExpectedSkillsShSource = {
|
||||
repo: string;
|
||||
externalId: string;
|
||||
path: string;
|
||||
commit: string;
|
||||
contentHash: string;
|
||||
};
|
||||
|
||||
type GitHubSkillVerificationTarget = {
|
||||
skill: Pick<Doc<"skills">, "_id" | "slug" | "displayName" | "summary"> & {
|
||||
githubPath: string;
|
||||
@@ -1820,7 +1828,11 @@ export async function verifyGitHubSkillHandler(
|
||||
|
||||
export async function configurePublicGitHubSkillSourceHandler(
|
||||
ctx: ActionCtx,
|
||||
args: { ownerPublisherId: Id<"publishers">; repo: string },
|
||||
args: {
|
||||
ownerPublisherId: Id<"publishers">;
|
||||
repo: string;
|
||||
expectedSkillsShSource?: ExpectedSkillsShSource;
|
||||
},
|
||||
fetcher: typeof fetch = fetch,
|
||||
authOverride?: { userId: Id<"users"> },
|
||||
): Promise<SyncOneResult> {
|
||||
@@ -1849,12 +1861,16 @@ export async function configurePublicGitHubSkillSourceHandler(
|
||||
fetcher,
|
||||
);
|
||||
const revalidatedMetadata = await revalidateGitHubRepoMetadata(metadata, fetcher);
|
||||
if (args.expectedSkillsShSource) {
|
||||
assertExactSkillsShSourceSelection(snapshot, args.expectedSkillsShSource);
|
||||
}
|
||||
if (snapshot.skills.length === 0) {
|
||||
throw new ConvexError("No skills were found in that public GitHub repo.");
|
||||
}
|
||||
const canonicalRepo = normalizeRepo(revalidatedMetadata.repo).toLowerCase();
|
||||
return await applyFetchedGitHubSkillSourceSnapshot(ctx, {
|
||||
sourceId: setup.existingSource?._id,
|
||||
repo: revalidatedMetadata.repo,
|
||||
repo: canonicalRepo,
|
||||
ownerUserId: setup.ownerUserId,
|
||||
ownerPublisherId: args.ownerPublisherId,
|
||||
githubRepositoryId: revalidatedMetadata.repositoryId,
|
||||
@@ -1867,6 +1883,15 @@ export const configurePublicGitHubSkillSource: ReturnType<typeof action> = actio
|
||||
args: {
|
||||
ownerPublisherId: v.id("publishers"),
|
||||
repo: v.string(),
|
||||
expectedSkillsShSource: v.optional(
|
||||
v.object({
|
||||
repo: v.string(),
|
||||
externalId: v.string(),
|
||||
path: v.string(),
|
||||
commit: v.string(),
|
||||
contentHash: v.string(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
handler: async (ctx, args): Promise<SyncOneResult> =>
|
||||
configurePublicGitHubSkillSourceHandler(ctx, args),
|
||||
@@ -2350,7 +2375,7 @@ async function fetchPublicGitHubRepoMetadata(
|
||||
const response = await fetcher(
|
||||
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}`,
|
||||
{
|
||||
headers: await buildGitHubSkillSourceHeaders(fetcher),
|
||||
headers: await buildGitHubSkillSourceHeaders(fetcher, true),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
@@ -2412,32 +2437,44 @@ async function revalidateGitHubRepoMetadata(expected: GitHubRepoMetadata, fetche
|
||||
return current;
|
||||
}
|
||||
|
||||
async function buildGitHubSkillSourceHeaders(fetcher: typeof fetch) {
|
||||
async function buildGitHubSkillSourceHeaders(
|
||||
fetcher: typeof fetch,
|
||||
useOAuthAppClientCredentials = false,
|
||||
) {
|
||||
return await buildGitHubApiHeaders({
|
||||
userAgent: "clawhub/github-skill-source",
|
||||
fetchImpl: fetcher,
|
||||
// Installation tokens are repository-scoped and cannot reliably read an
|
||||
// arbitrary public repository selected by a publisher.
|
||||
useGitHubApp: false,
|
||||
useOAuthAppClientCredentials,
|
||||
});
|
||||
}
|
||||
|
||||
function buildGitHubSkillSourceFetch(fetcher: typeof fetch): typeof fetch {
|
||||
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (!shouldAttachGitHubSkillSourceHeaders(input)) return fetcher(input, init);
|
||||
const hostname = getGitHubSkillSourceHostname(input);
|
||||
if (!hostname) return fetcher(input, init);
|
||||
const headers = new Headers(init?.headers);
|
||||
for (const [key, value] of Object.entries(await buildGitHubSkillSourceHeaders(fetcher))) {
|
||||
for (const [key, value] of Object.entries(
|
||||
await buildGitHubSkillSourceHeaders(fetcher, hostname === "api.github.com"),
|
||||
)) {
|
||||
if (!headers.has(key)) headers.set(key, value);
|
||||
}
|
||||
return fetcher(input, { ...init, headers });
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
function shouldAttachGitHubSkillSourceHeaders(input: RequestInfo | URL) {
|
||||
function getGitHubSkillSourceHostname(input: RequestInfo | URL) {
|
||||
const urlString =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
return url.hostname === "api.github.com" || url.hostname === "codeload.github.com";
|
||||
return url.hostname === "api.github.com" || url.hostname === "codeload.github.com"
|
||||
? url.hostname
|
||||
: null;
|
||||
} catch {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2547,6 +2584,44 @@ function normalizeRepoPath(path: string) {
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
function assertExactSkillsShSourceSelection(
|
||||
snapshot: GitHubSkillSourceSnapshot,
|
||||
expected: ExpectedSkillsShSource,
|
||||
) {
|
||||
const repo = normalizeRepoOrNull(expected.repo);
|
||||
const externalSegments = expected.externalId.trim().toLowerCase().split("/").filter(Boolean);
|
||||
const path = normalizeRepoPath(expected.path);
|
||||
const commit = expected.commit.trim().toLowerCase();
|
||||
const contentHash = expected.contentHash.trim().toLowerCase();
|
||||
const expectedSlug = externalSegments.length === 3 ? externalSegments[2] : null;
|
||||
const matches = snapshot.skills.filter(
|
||||
(skill) => skill.path === path && skill.slug.toLowerCase() === expectedSlug,
|
||||
);
|
||||
if (
|
||||
!expectedSlug ||
|
||||
!repo ||
|
||||
snapshot.repo.trim().toLowerCase() !== repo ||
|
||||
!path ||
|
||||
!/^[a-f0-9]{40}$/.test(commit) ||
|
||||
!/^[a-f0-9]{64}$/.test(contentHash) ||
|
||||
snapshot.commit.trim().toLowerCase() !== commit ||
|
||||
matches.length !== 1 ||
|
||||
matches[0]?.contentHash.trim().toLowerCase() !== contentHash
|
||||
) {
|
||||
throw new ConvexError(
|
||||
"The GitHub source changed since this skills.sh listing was observed. Refresh the listing before claiming it.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRepoOrNull(value: string) {
|
||||
try {
|
||||
return normalizeRepo(value).toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stripUndefined<T extends Record<string, unknown>>(value: T): Partial<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter((entry) => entry[1] !== undefined),
|
||||
@@ -2554,6 +2629,7 @@ function stripUndefined<T extends Record<string, unknown>>(value: T): Partial<T>
|
||||
}
|
||||
|
||||
export const __test = {
|
||||
assertExactSkillsShSourceSelection,
|
||||
buildGitHubSkillSourceFetch,
|
||||
buildGitHubSourceImport,
|
||||
normalizeRepo,
|
||||
|
||||
@@ -99,4 +99,36 @@ describe("githubAuth", () => {
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can authenticate public API reads with the configured OAuth app", async () => {
|
||||
vi.stubEnv("GITHUB_TOKEN", "");
|
||||
vi.stubEnv("AUTH_GITHUB_ID", "oauth-client-id");
|
||||
vi.stubEnv("AUTH_GITHUB_SECRET", "oauth-client-secret");
|
||||
|
||||
await expect(
|
||||
buildGitHubApiHeaders({
|
||||
userAgent: "clawhub/test",
|
||||
useGitHubApp: false,
|
||||
useOAuthAppClientCredentials: true,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Basic ${btoa("oauth-client-id:oauth-client-secret")}`,
|
||||
"User-Agent": "clawhub/test",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers a general token over OAuth app client credentials", async () => {
|
||||
vi.stubEnv("GITHUB_TOKEN", "ghp_pat_token");
|
||||
vi.stubEnv("AUTH_GITHUB_ID", "oauth-client-id");
|
||||
vi.stubEnv("AUTH_GITHUB_SECRET", "oauth-client-secret");
|
||||
|
||||
await expect(
|
||||
buildGitHubApiHeaders({
|
||||
userAgent: "clawhub/test",
|
||||
useGitHubApp: false,
|
||||
useOAuthAppClientCredentials: true,
|
||||
}),
|
||||
).resolves.toMatchObject({ Authorization: "Bearer ghp_pat_token" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,11 @@ type GitHubAppConfig = {
|
||||
privateKey: string;
|
||||
};
|
||||
|
||||
type GitHubOAuthAppConfig = {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
type InstallationToken = {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
@@ -33,6 +38,7 @@ export async function buildGitHubApiHeaders(options: {
|
||||
fetchImpl?: FetchImpl;
|
||||
allowAnonymous?: boolean;
|
||||
useGitHubApp?: boolean;
|
||||
useOAuthAppClientCredentials?: boolean;
|
||||
}): Promise<Record<string, string>> {
|
||||
const headers = buildGitHubHeaders({
|
||||
userAgent: options.userAgent,
|
||||
@@ -56,6 +62,14 @@ export async function buildGitHubApiHeaders(options: {
|
||||
return headers;
|
||||
}
|
||||
|
||||
if (options.useOAuthAppClientCredentials) {
|
||||
const oauthApp = readGitHubOAuthAppConfig(process.env);
|
||||
if (oauthApp) {
|
||||
headers.Authorization = `Basic ${base64String(`${oauthApp.clientId}:${oauthApp.clientSecret}`)}`;
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.allowAnonymous === false) {
|
||||
throw new Error("GitHub API authentication is not configured");
|
||||
}
|
||||
@@ -157,6 +171,13 @@ function readGitHubAppConfig(env: NodeJS.ProcessEnv): GitHubAppConfig | null {
|
||||
return { appId, installationId, privateKey };
|
||||
}
|
||||
|
||||
function readGitHubOAuthAppConfig(env: NodeJS.ProcessEnv): GitHubOAuthAppConfig | null {
|
||||
const clientId = env.AUTH_GITHUB_ID?.trim();
|
||||
const clientSecret = env.AUTH_GITHUB_SECRET?.trim();
|
||||
if (!clientId || !clientSecret) return null;
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
async function createGitHubAppJwt(appId: string, rawPrivateKey: string, nowMs: number) {
|
||||
const now = Math.floor(nowMs / 1000);
|
||||
const header = { alg: "RS256", typ: "JWT" };
|
||||
@@ -249,6 +270,12 @@ function base64UrlString(value: string) {
|
||||
return base64UrlBytes(new TextEncoder().encode(value));
|
||||
}
|
||||
|
||||
function base64String(value: string) {
|
||||
let binary = "";
|
||||
for (const byte of new TextEncoder().encode(value)) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64UrlBytes(value: Uint8Array) {
|
||||
let binary = "";
|
||||
for (const byte of value) binary += String.fromCharCode(byte);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildSkillsShMirrorCatalogDetail,
|
||||
buildSkillsShCanonicalGitHubRepo,
|
||||
buildSkillsShMirrorIdentity,
|
||||
buildUnclaimedSkillsShInstallResolution,
|
||||
buildUnclaimedSkillsShVerifyResponse,
|
||||
@@ -85,6 +86,31 @@ describe("skills.sh mirror public contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the canonical GitHub repository after an upstream redirect", () => {
|
||||
const redirected = {
|
||||
...digest,
|
||||
canonicalRepoUrl: "https://github.com/openclaw/openclaw",
|
||||
};
|
||||
|
||||
expect(buildSkillsShCanonicalGitHubRepo(redirected)).toBe("openclaw/openclaw");
|
||||
expect(buildUnclaimedSkillsShInstallResolution(redirected)).toMatchObject({
|
||||
github: { repo: "openclaw/openclaw" },
|
||||
});
|
||||
expect(buildSkillsShMirrorCatalogDetail({ digest: redirected, detail })).toMatchObject({
|
||||
canonicalGitHubRepo: "openclaw/openclaw",
|
||||
githubContentHash: digest.sourceContentHash,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-GitHub canonical repository URLs", () => {
|
||||
expect(
|
||||
buildSkillsShCanonicalGitHubRepo({
|
||||
...digest,
|
||||
canonicalRepoUrl: "https://example.com/openclaw/openclaw",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("renders only content whose stored hash matches the digest", () => {
|
||||
expect(buildSkillsShMirrorCatalogDetail({ digest, detail })).toMatchObject({
|
||||
summary: "Choose and build HTML artifacts.",
|
||||
|
||||
@@ -91,6 +91,32 @@ export function buildSkillsShMirrorIdentity(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSkillsShCanonicalGitHubRepo(
|
||||
digest: Pick<SkillsShMirrorDigest, "canonicalRepoUrl" | "owner" | "repo">,
|
||||
) {
|
||||
if (!digest.canonicalRepoUrl) {
|
||||
const owner = normalizeSegment(digest.owner);
|
||||
const repo = normalizeSegment(digest.repo);
|
||||
return owner && repo ? `${owner}/${repo}` : null;
|
||||
}
|
||||
try {
|
||||
const url = new URL(digest.canonicalRepoUrl);
|
||||
if (url.protocol !== "https:" || !["github.com", "www.github.com"].includes(url.hostname)) {
|
||||
return null;
|
||||
}
|
||||
const segments = url.pathname
|
||||
.replace(/\.git$/i, "")
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
if (segments.length !== 2) return null;
|
||||
const owner = normalizeSegment(segments[0]);
|
||||
const repo = normalizeSegment(segments[1]);
|
||||
return owner && repo ? `${owner}/${repo}` : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isPublicSkillsShMirrorDigest(digest: SkillsShMirrorDigest) {
|
||||
return (
|
||||
digest.active &&
|
||||
@@ -135,7 +161,8 @@ export function buildSkillsShMirrorCatalogDetail(args: {
|
||||
detail: SkillsShMirrorDetail | null;
|
||||
}) {
|
||||
const identity = buildSkillsShMirrorIdentity(args.digest);
|
||||
if (!identity || !isPublicSkillsShMirrorDigest(args.digest)) return null;
|
||||
const canonicalGitHubRepo = buildSkillsShCanonicalGitHubRepo(args.digest);
|
||||
if (!identity || !canonicalGitHubRepo || !isPublicSkillsShMirrorDigest(args.digest)) return null;
|
||||
const digestHash = args.digest.sourceContentHash?.trim().toLowerCase();
|
||||
const detailHash = args.detail?.sourceContentHash?.trim().toLowerCase();
|
||||
const content =
|
||||
@@ -161,8 +188,11 @@ export function buildSkillsShMirrorCatalogDetail(args: {
|
||||
lastObservedAt: args.digest.lastObservedAt,
|
||||
sourceUrl: args.digest.sourceUrl,
|
||||
canonicalRepoUrl: args.digest.canonicalRepoUrl,
|
||||
canonicalGitHubRepo,
|
||||
githubPath: args.digest.githubPath,
|
||||
githubCommit: args.digest.githubCommit,
|
||||
// The permanent mirror stores the exact GitHub folder hash under this source field.
|
||||
githubContentHash: args.digest.sourceContentHash,
|
||||
sourceContentHash: args.digest.sourceContentHash,
|
||||
upstreamChecks: [
|
||||
buildUpstreamCheck(UPSTREAM_SCANNERS[0], args.digest.upstreamScanners.genAgentTrustHub),
|
||||
@@ -197,11 +227,13 @@ export function buildGitHubTreeUrl(repo: string, commit: string, path: string) {
|
||||
|
||||
export function buildUnclaimedSkillsShInstallResolution(digest: SkillsShMirrorDigest) {
|
||||
const identity = buildSkillsShMirrorIdentity(digest);
|
||||
const repo = buildSkillsShCanonicalGitHubRepo(digest);
|
||||
const path = normalizeGitHubPath(digest.githubPath);
|
||||
const commit = digest.githubCommit?.trim().toLowerCase();
|
||||
const contentHash = digest.sourceContentHash?.trim().toLowerCase();
|
||||
if (
|
||||
!identity ||
|
||||
!repo ||
|
||||
!isPublicSkillsShMirrorDigest(digest) ||
|
||||
!path ||
|
||||
!commit ||
|
||||
@@ -211,7 +243,6 @@ export function buildUnclaimedSkillsShInstallResolution(digest: SkillsShMirrorDi
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const repo = `${identity.owner}/${identity.repo}`;
|
||||
return {
|
||||
ok: true as const,
|
||||
slug: identity.reference,
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("skillsShMirrorPublic.getSkillsShMirrorByRoute", () => {
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
externalId: "patrick-erichsen/skills/html",
|
||||
canonicalRepoUrl: "https://github.com/openclaw/openclaw",
|
||||
githubPath: "skills/html",
|
||||
publicVisible: false,
|
||||
installable: false,
|
||||
@@ -31,6 +32,10 @@ describe("skillsShMirrorPublic.getSkillsShMirrorByRoute", () => {
|
||||
canonicalRoute: "/openclaw/skills/html",
|
||||
canonicalRef: "@openclaw/html",
|
||||
});
|
||||
expect(runQuery.mock.calls[2]?.[1]).toEqual({
|
||||
repo: "openclaw/openclaw",
|
||||
path: "skills/html",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null while the skills.sh runtime is disabled", async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { QueryCtx } from "./_generated/server";
|
||||
import { query } from "./functions";
|
||||
import { getRuntimeRolloutCapabilities } from "./lib/rolloutCapabilities";
|
||||
import {
|
||||
buildSkillsShCanonicalGitHubRepo,
|
||||
buildSkillsShMirrorCatalogDetail,
|
||||
type SkillsShMirrorDetail,
|
||||
type SkillsShMirrorDigest,
|
||||
@@ -62,12 +63,14 @@ export async function getSkillsShMirrorByRoute(
|
||||
]);
|
||||
// A promoted native repo/path remains the canonical compatibility target even
|
||||
// after the external listing itself is no longer published.
|
||||
const alias = digest?.githubPath
|
||||
? ((await ctx.runQuery(
|
||||
internalRefs.githubSkillSources.getSkillsShAliasTargetInternal as never,
|
||||
{ repo: `${owner}/${repo}`, path: digest.githubPath } as never,
|
||||
)) as { canonicalRoute: string; canonicalRef: string } | null)
|
||||
: null;
|
||||
const canonicalRepo = digest ? buildSkillsShCanonicalGitHubRepo(digest) : null;
|
||||
const alias =
|
||||
digest?.githubPath && canonicalRepo
|
||||
? ((await ctx.runQuery(
|
||||
internalRefs.githubSkillSources.getSkillsShAliasTargetInternal as never,
|
||||
{ repo: canonicalRepo, path: digest.githubPath } as never,
|
||||
)) as { canonicalRoute: string; canonicalRef: string } | null)
|
||||
: null;
|
||||
if (alias) return { kind: "redirect" as const, ...alias };
|
||||
if (!digest) return null;
|
||||
const entry = buildSkillsShMirrorCatalogDetail({ digest, detail });
|
||||
|
||||
@@ -85,6 +85,28 @@ not create or mutate `skills` rows during its planning gates.
|
||||
leave native skills, download history, scan jobs, publishers, and aliases
|
||||
unchanged.
|
||||
|
||||
## Claiming mirrored skills.sh listings
|
||||
|
||||
Claim is a preselected entry into GitHub Skill Sync, not a separate adoption or
|
||||
scan state machine. The handoff carries the mirror's external identity plus its
|
||||
canonical GitHub repository, exact skill path, commit, and content hash.
|
||||
GitHub Skill Sync re-fetches the repository, revalidates immutable repository
|
||||
and owner IDs, and rejects the request before writes unless the selected skill
|
||||
still matches every frozen source field.
|
||||
|
||||
After that check, the normal GitHub Skill Sync lifecycle owns creation,
|
||||
controlled replacement, scanning, and promotion. New destinations remain
|
||||
hidden while pending. Existing allowed Hosted or GitHub content remains active
|
||||
while its candidate is pending or rejected. Promotion reuses the existing
|
||||
`skills` row, so routes, metrics, bookmarks, official state, prior versions,
|
||||
and audit history stay attached to the canonical identity.
|
||||
|
||||
The skills.sh route and `skills-sh:` install reference resolve their canonical
|
||||
GitHub repository from the stored redirect target. They remain external until
|
||||
the matching GitHub-backed skill has an allowed ClawHub verdict, then resolve
|
||||
dynamically by canonical repository plus exact path. No hosted archive or
|
||||
duplicated alias/adoption row is created for GitHub-backed content.
|
||||
|
||||
`skills` stores the public catalog row and install state:
|
||||
|
||||
- `installKind: "github"`
|
||||
|
||||
@@ -44,9 +44,21 @@ describe("SkillsShCatalogDetailPage", () => {
|
||||
expect(
|
||||
screen.getByText("clawhub install skills-sh:patrick-erichsen/skills/html", { exact: true }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "Claim" }).getAttribute("href")).toBe(
|
||||
"/settings?view=githubSources&repo=patrick-erichsen%2Fskills&sourcePath=skills%2Fhtml",
|
||||
const claimUrl = new URL(
|
||||
screen.getByRole("link", { name: "Claim" }).getAttribute("href") ?? "",
|
||||
"https://clawhub.test",
|
||||
);
|
||||
expect(claimUrl.pathname).toBe("/settings");
|
||||
expect(Object.fromEntries(claimUrl.searchParams)).toEqual({
|
||||
view: "githubSources",
|
||||
ownerHandle: "openclaw",
|
||||
repo: "openclaw/openclaw",
|
||||
sourceRepo: "openclaw/openclaw",
|
||||
sourceExternalId: "patrick-erichsen/skills/html",
|
||||
sourcePath: "skills/html",
|
||||
sourceCommit: "050daba89f6b6636470add5cb300aac46a412cf8",
|
||||
sourceContentHash: "a".repeat(64),
|
||||
});
|
||||
});
|
||||
|
||||
it("renders only stored bounded content and no file explorer", () => {
|
||||
@@ -83,9 +95,11 @@ function makeEntry(): SkillsShCatalogDetail {
|
||||
topics: [],
|
||||
sourceUrl: "https://skills.sh/patrick-erichsen/skills/html",
|
||||
canonicalRepoUrl: "https://github.com/patrick-erichsen/skills",
|
||||
canonicalGitHubRepo: "openclaw/openclaw",
|
||||
githubPath: "skills/html",
|
||||
githubCommit: "050daba89f6b6636470add5cb300aac46a412cf8",
|
||||
sourceContentHash: "a".repeat(64),
|
||||
githubContentHash: "a".repeat(64),
|
||||
sourceContentHash: "b".repeat(64),
|
||||
upstreamInstalls: 100,
|
||||
lastObservedAt: Date.now() - 60000,
|
||||
upstreamChecks: [
|
||||
|
||||
@@ -98,14 +98,19 @@ export function SkillsShCatalogDetailPage({ entry }: { entry: SkillsShCatalogDet
|
||||
<h2 className="font-display text-lg font-bold text-[color:var(--oc-text-primary)]">
|
||||
Install
|
||||
</h2>
|
||||
{entry.owner && entry.repo && entry.githubPath ? (
|
||||
{entry.githubPath && entry.githubCommit && entry.githubContentHash ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link
|
||||
to="/settings"
|
||||
search={{
|
||||
view: "githubSources",
|
||||
repo: `${entry.owner}/${entry.repo}`,
|
||||
ownerHandle: entry.canonicalGitHubRepo.split("/")[0],
|
||||
repo: entry.canonicalGitHubRepo,
|
||||
sourceRepo: entry.canonicalGitHubRepo,
|
||||
sourceExternalId: entry.externalId,
|
||||
sourcePath: entry.githubPath,
|
||||
sourceCommit: entry.githubCommit,
|
||||
sourceContentHash: entry.githubContentHash,
|
||||
}}
|
||||
>
|
||||
<GitBranch size={15} aria-hidden="true" /> Claim
|
||||
|
||||
@@ -28,8 +28,10 @@ export type SkillsShCatalogDetail = SkillsShSearchResult & {
|
||||
topics: string[];
|
||||
sourceUrl: string;
|
||||
canonicalRepoUrl?: string;
|
||||
canonicalGitHubRepo: string;
|
||||
githubPath?: string;
|
||||
githubCommit?: string;
|
||||
githubContentHash?: string;
|
||||
sourceContentHash?: string;
|
||||
upstreamChecks: SkillsShUpstreamCheck[];
|
||||
content: {
|
||||
@@ -104,11 +106,11 @@ export function buildSkillsShInstallCommands(reference: string) {
|
||||
}
|
||||
|
||||
export function isSkillsShCatalogInstallable(
|
||||
detail: Pick<SkillsShCatalogDetail, "githubCommit" | "githubPath" | "sourceContentHash">,
|
||||
detail: Pick<SkillsShCatalogDetail, "githubCommit" | "githubContentHash" | "githubPath">,
|
||||
) {
|
||||
return Boolean(
|
||||
detail.githubPath?.trim() &&
|
||||
/^[a-f0-9]{40}$/.test(detail.githubCommit?.trim().toLowerCase() ?? "") &&
|
||||
/^[a-f0-9]{64}$/.test(detail.sourceContentHash?.trim().toLowerCase() ?? ""),
|
||||
/^[a-f0-9]{64}$/.test(detail.githubContentHash?.trim().toLowerCase() ?? ""),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -688,12 +688,76 @@ describe("Settings", () => {
|
||||
expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/GitHub source synced/i));
|
||||
});
|
||||
|
||||
it("prefills the repository and source path from an external Claim handoff", async () => {
|
||||
it("passes the exact external selection from Claim into GitHub Skill Sync", async () => {
|
||||
const configureSource = vi.fn().mockResolvedValue({ ok: true, stats: { discovered: 1 } });
|
||||
useActionMock.mockReturnValue(configureSource);
|
||||
mockSignedInSettings({
|
||||
search: {
|
||||
view: "githubSources",
|
||||
ownerHandle: "openclaw",
|
||||
repo: "patrick-erichsen/skills",
|
||||
sourceRepo: "patrick-erichsen/skills",
|
||||
sourceExternalId: "patrick-erichsen/skills/html",
|
||||
sourcePath: "skills/html",
|
||||
sourceCommit: "1".repeat(40),
|
||||
sourceContentHash: "2".repeat(64),
|
||||
},
|
||||
memberships: [orgMembership],
|
||||
});
|
||||
|
||||
const view = render(<Settings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText("GitHub repo URL") as HTMLInputElement).value).toBe(
|
||||
"patrick-erichsen/skills",
|
||||
);
|
||||
});
|
||||
expect(screen.getByText("skills/html")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Add repo/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(configureSource).toHaveBeenCalledWith({
|
||||
ownerPublisherId: "publisher_openclaw",
|
||||
repo: "patrick-erichsen/skills",
|
||||
expectedSkillsShSource: {
|
||||
repo: "patrick-erichsen/skills",
|
||||
externalId: "patrick-erichsen/skills/html",
|
||||
path: "skills/html",
|
||||
commit: "1".repeat(40),
|
||||
contentHash: "2".repeat(64),
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
to: "/settings",
|
||||
search: { view: "githubSources" },
|
||||
replace: true,
|
||||
});
|
||||
|
||||
searchMock.mockReturnValue({ view: "githubSources" });
|
||||
view.rerender(<Settings />);
|
||||
fireEvent.change(screen.getByLabelText("GitHub repo URL"), {
|
||||
target: { value: "NVIDIA/skills" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Add repo/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(configureSource).toHaveBeenNthCalledWith(2, {
|
||||
ownerPublisherId: "publisher_openclaw",
|
||||
repo: "NVIDIA/skills",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a partial external Claim selection before GitHub Skill Sync", async () => {
|
||||
const configureSource = vi.fn();
|
||||
useActionMock.mockReturnValue(configureSource);
|
||||
mockSignedInSettings({
|
||||
search: {
|
||||
view: "githubSources",
|
||||
repo: "patrick-erichsen/skills",
|
||||
sourcePath: "skills/html",
|
||||
sourceExternalId: "patrick-erichsen/skills/html",
|
||||
sourcePath: "",
|
||||
},
|
||||
memberships: [orgMembership],
|
||||
});
|
||||
@@ -705,7 +769,10 @@ describe("Settings", () => {
|
||||
"patrick-erichsen/skills",
|
||||
);
|
||||
});
|
||||
expect(screen.getByText("skills/html")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Add repo/i }));
|
||||
|
||||
expect(configureSource).not.toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/Claim link is incomplete/i));
|
||||
});
|
||||
|
||||
it("shows synced repos as separate cards and lets owners delete a source", async () => {
|
||||
|
||||
@@ -90,12 +90,22 @@ export const Route = createFileRoute("/settings")({
|
||||
view?: SettingsView;
|
||||
ownerHandle?: string;
|
||||
repo?: string;
|
||||
sourceRepo?: string;
|
||||
sourceExternalId?: string;
|
||||
sourcePath?: string;
|
||||
sourceCommit?: string;
|
||||
sourceContentHash?: string;
|
||||
} => ({
|
||||
view: isSettingsView(search.view) ? search.view : undefined,
|
||||
ownerHandle: typeof search.ownerHandle === "string" ? search.ownerHandle : undefined,
|
||||
repo: typeof search.repo === "string" ? search.repo : undefined,
|
||||
sourceRepo: typeof search.sourceRepo === "string" ? search.sourceRepo : undefined,
|
||||
sourceExternalId:
|
||||
typeof search.sourceExternalId === "string" ? search.sourceExternalId : undefined,
|
||||
sourcePath: typeof search.sourcePath === "string" ? search.sourcePath : undefined,
|
||||
sourceCommit: typeof search.sourceCommit === "string" ? search.sourceCommit : undefined,
|
||||
sourceContentHash:
|
||||
typeof search.sourceContentHash === "string" ? search.sourceContentHash : undefined,
|
||||
}),
|
||||
component: Settings,
|
||||
});
|
||||
@@ -343,7 +353,11 @@ export function Settings() {
|
||||
navigateToView,
|
||||
ownerHandle: requestedOwnerHandle,
|
||||
repo: requestedRepo,
|
||||
sourceRepo: requestedSourceRepo,
|
||||
sourceExternalId: requestedSourceExternalId,
|
||||
sourcePath: requestedSourcePath,
|
||||
sourceCommit: requestedSourceCommit,
|
||||
sourceContentHash: requestedSourceContentHash,
|
||||
} = useActiveSettingsView();
|
||||
const orgs = (publisherMemberships ?? []).filter((entry) => entry.publisher.kind === "org");
|
||||
const manageablePublishers = (publisherMemberships ?? []).filter(
|
||||
@@ -752,12 +766,44 @@ export function Settings() {
|
||||
if (!selectedSourcePublisher) return;
|
||||
const repo = parseGitHubRepoInput(githubRepo);
|
||||
if (!repo) return;
|
||||
const requestedSourceFields = [
|
||||
requestedSourceRepo,
|
||||
requestedSourceExternalId,
|
||||
requestedSourcePath,
|
||||
requestedSourceCommit,
|
||||
requestedSourceContentHash,
|
||||
];
|
||||
const hasRequestedSource = requestedSourceFields.some((value) => value !== undefined);
|
||||
const hasCompleteRequestedSource = requestedSourceFields.every(
|
||||
(value) => typeof value === "string" && value.trim().length > 0,
|
||||
);
|
||||
if (hasRequestedSource && !hasCompleteRequestedSource) {
|
||||
toast.error("This Claim link is incomplete. Return to the skill listing and try again.");
|
||||
return;
|
||||
}
|
||||
const expectedSkillsShSource = hasCompleteRequestedSource
|
||||
? {
|
||||
repo: requestedSourceRepo as string,
|
||||
externalId: requestedSourceExternalId as string,
|
||||
path: requestedSourcePath as string,
|
||||
commit: requestedSourceCommit as string,
|
||||
contentHash: requestedSourceContentHash as string,
|
||||
}
|
||||
: undefined;
|
||||
setIsSyncingSource(true);
|
||||
try {
|
||||
const result = await configureGitHubSource({
|
||||
ownerPublisherId: selectedSourcePublisher.publisher._id,
|
||||
repo,
|
||||
...(expectedSkillsShSource ? { expectedSkillsShSource } : {}),
|
||||
});
|
||||
if (expectedSkillsShSource) {
|
||||
await navigate({
|
||||
to: "/settings",
|
||||
search: { view: "githubSources" },
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
setGithubRepo("");
|
||||
toast.success(formatGitHubSourceSyncToast(result?.stats));
|
||||
} catch (error) {
|
||||
@@ -3013,7 +3059,13 @@ function useActiveSettingsView() {
|
||||
navigateToView,
|
||||
ownerHandle: typeof search.ownerHandle === "string" ? search.ownerHandle : undefined,
|
||||
repo: typeof search.repo === "string" ? search.repo : undefined,
|
||||
sourceRepo: typeof search.sourceRepo === "string" ? search.sourceRepo : undefined,
|
||||
sourceExternalId:
|
||||
typeof search.sourceExternalId === "string" ? search.sourceExternalId : undefined,
|
||||
sourcePath: typeof search.sourcePath === "string" ? search.sourcePath : undefined,
|
||||
sourceCommit: typeof search.sourceCommit === "string" ? search.sourceCommit : undefined,
|
||||
sourceContentHash:
|
||||
typeof search.sourceContentHash === "string" ? search.sourceContentHash : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user