mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Add package changelog previews for plugin publishing (#2947)
* Add package changelog previews * fix: gate package changelog previews * Fix package changelog preview races
This commit is contained in:
@@ -31,4 +31,16 @@ describe("changelog utils", () => {
|
||||
});
|
||||
expect(text).toMatch(/Initial release/i);
|
||||
});
|
||||
|
||||
it("generates a package-specific fallback update note", () => {
|
||||
const text = __test.generatePackageFallback({
|
||||
name: "demo-plugin",
|
||||
version: "1.2.0",
|
||||
oldReadme: "old",
|
||||
nextReadme: "new",
|
||||
fileDiff: { added: ["src/index.ts"], changed: [], removed: [] },
|
||||
});
|
||||
expect(text).toContain("Updated README and package contents");
|
||||
expect(text).not.toContain("SKILL.md");
|
||||
});
|
||||
});
|
||||
|
||||
+99
-5
@@ -61,6 +61,8 @@ function pickPaths(values: string[]) {
|
||||
}
|
||||
|
||||
async function generateWithOpenAI(args: {
|
||||
subjectLabel?: string;
|
||||
readmeLabel?: string;
|
||||
slug: string;
|
||||
version: string;
|
||||
oldReadme: string | null;
|
||||
@@ -70,6 +72,8 @@ async function generateWithOpenAI(args: {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
|
||||
const subjectLabel = args.subjectLabel ?? "Skill";
|
||||
const readmeLabel = args.readmeLabel ?? "SKILL.md";
|
||||
const oldReadme = args.oldReadme ? clampText(args.oldReadme, MAX_README_CHARS) : "";
|
||||
const nextReadme = clampText(args.nextReadme, MAX_README_CHARS);
|
||||
|
||||
@@ -80,14 +84,14 @@ async function generateWithOpenAI(args: {
|
||||
const removedPaths = fileDiff ? pickPaths(fileDiff.removed) : [];
|
||||
|
||||
const input = [
|
||||
`Skill: ${args.slug}`,
|
||||
`${subjectLabel}: ${args.slug}`,
|
||||
`Version: ${args.version}`,
|
||||
`File changes: ${diffSummary}`,
|
||||
changedPaths.length ? `Changed files (sample): ${changedPaths.join(", ")}` : null,
|
||||
addedPaths.length ? `Added files (sample): ${addedPaths.join(", ")}` : null,
|
||||
removedPaths.length ? `Removed files (sample): ${removedPaths.join(", ")}` : null,
|
||||
oldReadme ? `Previous SKILL.md:\n${oldReadme}` : null,
|
||||
`New SKILL.md:\n${nextReadme}`,
|
||||
oldReadme ? `Previous ${readmeLabel}:\n${oldReadme}` : null,
|
||||
`New ${readmeLabel}:\n${nextReadme}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
@@ -100,8 +104,7 @@ async function generateWithOpenAI(args: {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: CHANGELOG_MODEL,
|
||||
instructions:
|
||||
"Write a concise changelog for this skill version. Audience: everyone. Output plain text. Prefer 2–6 bullet points. If it is a big change, include a short 1-line summary first, then bullets. Don’t mention that you are AI. Don’t invent details; only use the inputs.",
|
||||
instructions: `Write a concise changelog for this ${subjectLabel.toLowerCase()} version. Audience: everyone. Output plain text. Prefer 2–6 bullet points. If it is a big change, include a short 1-line summary first, then bullets. Don’t mention that you are AI. Don’t invent details; only use the inputs.`,
|
||||
input,
|
||||
max_output_tokens: 220,
|
||||
}),
|
||||
@@ -138,6 +141,32 @@ function generateFallback(args: {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function generatePackageFallback(args: {
|
||||
name: string;
|
||||
version: string;
|
||||
oldReadme: string | null;
|
||||
nextReadme: string;
|
||||
fileDiff: FileDiffSummary | null;
|
||||
}) {
|
||||
const lines: string[] = [];
|
||||
if (!args.oldReadme) {
|
||||
lines.push(`- Initial release.`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const diff = args.fileDiff;
|
||||
if (diff) {
|
||||
const parts: string[] = [];
|
||||
if (diff.added.length) parts.push(`added ${diff.added.length}`);
|
||||
if (diff.changed.length) parts.push(`updated ${diff.changed.length}`);
|
||||
if (diff.removed.length) parts.push(`removed ${diff.removed.length}`);
|
||||
if (parts.length) lines.push(`- ${parts.join(", ")} file(s).`);
|
||||
}
|
||||
|
||||
lines.push(`- Updated README and package contents.`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function generateChangelogForPublish(
|
||||
ctx: ActionCtx,
|
||||
args: { slug: string; version: string; readmeText: string; files: FileMeta[] },
|
||||
@@ -184,6 +213,59 @@ export async function generateChangelogForPublish(
|
||||
}
|
||||
}
|
||||
|
||||
export async function generatePackageChangelogPreview(
|
||||
ctx: ActionCtx,
|
||||
args: {
|
||||
name: string;
|
||||
version: string;
|
||||
readmeText: string;
|
||||
filePaths?: string[];
|
||||
latestReleaseId?: Id<"packageReleases"> | null;
|
||||
},
|
||||
): Promise<string> {
|
||||
try {
|
||||
const previous: Doc<"packageReleases"> | null = args.latestReleaseId
|
||||
? ((await ctx.runQuery(internal.packages.getReleaseByIdInternal, {
|
||||
releaseId: args.latestReleaseId,
|
||||
})) as Doc<"packageReleases"> | null)
|
||||
: null;
|
||||
|
||||
const oldReadmeText: string | null = previous
|
||||
? await readReadmeFromPackageRelease(ctx, previous)
|
||||
: null;
|
||||
const fileDiff =
|
||||
previous && args.filePaths
|
||||
? summarizeFileDiff(
|
||||
previous.files.map((file) => ({ path: file.path, sha256: file.sha256 })),
|
||||
args.filePaths.map((path) => ({ path })),
|
||||
)
|
||||
: null;
|
||||
|
||||
const ai = await generateWithOpenAI({
|
||||
subjectLabel: "Package",
|
||||
readmeLabel: "README",
|
||||
slug: args.name,
|
||||
version: args.version,
|
||||
oldReadme: oldReadmeText,
|
||||
nextReadme: args.readmeText,
|
||||
fileDiff,
|
||||
}).catch(() => null);
|
||||
|
||||
return (
|
||||
ai ??
|
||||
generatePackageFallback({
|
||||
name: args.name,
|
||||
version: args.version,
|
||||
oldReadme: oldReadmeText,
|
||||
nextReadme: args.readmeText,
|
||||
fileDiff,
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
return "- Updated package.";
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateChangelogPreview(
|
||||
ctx: ActionCtx,
|
||||
args: {
|
||||
@@ -249,10 +331,22 @@ async function readReadmeFromVersion(ctx: ActionCtx, version: Doc<"skillVersions
|
||||
return blob.text();
|
||||
}
|
||||
|
||||
async function readReadmeFromPackageRelease(ctx: ActionCtx, release: Doc<"packageReleases">) {
|
||||
const readmeFile = release.files.find((file) => {
|
||||
const lower = file.path.toLowerCase();
|
||||
return lower === "readme.md" || lower === "readme.mdx";
|
||||
});
|
||||
if (!readmeFile) return null;
|
||||
const blob = await ctx.storage.get(readmeFile.storageId as Id<"_storage">);
|
||||
if (!blob) return null;
|
||||
return blob.text();
|
||||
}
|
||||
|
||||
export const __test = {
|
||||
clampText,
|
||||
extractResponseText,
|
||||
formatDiffSummary,
|
||||
generatePackageFallback,
|
||||
summarizeFileDiff,
|
||||
generateFallback,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
list,
|
||||
publishPackageForTrustedPublisherInternal,
|
||||
publishPackageForUserInternal,
|
||||
generateChangelogPreview,
|
||||
assertCanGenerateChangelogPreviewInternal,
|
||||
listPackageReportsInternal,
|
||||
getPackageModerationStatusForUserInternal,
|
||||
getManageContext,
|
||||
@@ -409,6 +411,27 @@ const publishPackageForTrustedPublisherInternalHandler = (
|
||||
unknown
|
||||
>
|
||||
)._handler;
|
||||
const generateChangelogPreviewHandler = (
|
||||
generateChangelogPreview as unknown as WrappedHandler<
|
||||
{
|
||||
name: string;
|
||||
family: "code-plugin" | "bundle-plugin";
|
||||
version: string;
|
||||
readmeText: string;
|
||||
filePaths?: string[];
|
||||
},
|
||||
{ changelog: string }
|
||||
>
|
||||
)._handler;
|
||||
const assertCanGenerateChangelogPreviewInternalHandler = (
|
||||
assertCanGenerateChangelogPreviewInternal as unknown as WrappedHandler<
|
||||
{
|
||||
actorUserId: string;
|
||||
name: string;
|
||||
},
|
||||
{ ok: true }
|
||||
>
|
||||
)._handler;
|
||||
|
||||
const packageManifestFile = {
|
||||
path: "openclaw.plugin.json",
|
||||
@@ -2697,7 +2720,200 @@ function makeSoftDeletePackageCtx(options?: {
|
||||
};
|
||||
}
|
||||
|
||||
function makePackagePreviewAccessCtx(options: {
|
||||
actorUserId: string;
|
||||
actorRole?: "user" | "admin" | "moderator";
|
||||
pkg: ReturnType<typeof makePackageDoc> | null;
|
||||
membershipRole?: "owner" | "admin" | "publisher";
|
||||
}) {
|
||||
const publisher = options.pkg?.ownerPublisherId
|
||||
? {
|
||||
_id: options.pkg.ownerPublisherId,
|
||||
kind: "org",
|
||||
handle: "owner-org",
|
||||
displayName: "Owner Org",
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === options.actorUserId) return { _id: id, role: options.actorRole ?? "user" };
|
||||
if (publisher && id === publisher._id) return publisher;
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "packages") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(options.pkg),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "publisherMembers") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(
|
||||
options.membershipRole && publisher
|
||||
? {
|
||||
_id: "publisherMembers:preview",
|
||||
publisherId: publisher._id,
|
||||
userId: options.actorUserId,
|
||||
role: options.membershipRole,
|
||||
}
|
||||
: null,
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("packages public queries", () => {
|
||||
it("generates a package changelog preview from prior release history", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:preview" as never);
|
||||
const previousKey = process.env.OPENAI_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
try {
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "users:preview",
|
||||
role: "user",
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, latestReleaseId: "packageReleases:demo-1" })
|
||||
.mockResolvedValueOnce({
|
||||
_id: "packageReleases:demo-1",
|
||||
files: [{ path: "README.md", storageId: "storage:readme", sha256: "old" }],
|
||||
});
|
||||
const storageGet = vi.fn(async () => new Blob(["# Demo\n\nOld README"]));
|
||||
|
||||
const result = await generateChangelogPreviewHandler(
|
||||
{
|
||||
runQuery,
|
||||
storage: { get: storageGet },
|
||||
} as never,
|
||||
{
|
||||
name: "demo-plugin",
|
||||
family: "code-plugin",
|
||||
version: "1.2.0",
|
||||
readmeText: "# Demo\n\nNew README",
|
||||
filePaths: ["README.md", "src/index.ts"],
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.changelog).toContain("Updated README and package contents");
|
||||
expect(runQuery).toHaveBeenCalledTimes(3);
|
||||
expect(storageGet).toHaveBeenCalledWith("storage:readme");
|
||||
} finally {
|
||||
if (previousKey === undefined) {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = previousKey;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks package changelog previews before reading release storage when access is denied", async () => {
|
||||
vi.mocked(getAuthUserId).mockResolvedValue("users:preview" as never);
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "users:preview",
|
||||
role: "user",
|
||||
})
|
||||
.mockRejectedValueOnce(new Error("Forbidden"));
|
||||
const storageGet = vi.fn();
|
||||
|
||||
await expect(
|
||||
generateChangelogPreviewHandler(
|
||||
{
|
||||
runQuery,
|
||||
storage: { get: storageGet },
|
||||
} as never,
|
||||
{
|
||||
name: "demo-plugin",
|
||||
family: "code-plugin",
|
||||
version: "1.2.0",
|
||||
readmeText: "# Demo\n\nNew README",
|
||||
filePaths: ["README.md"],
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("Forbidden");
|
||||
|
||||
expect(runQuery).toHaveBeenCalledTimes(2);
|
||||
expect(storageGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-owner package changelog previews for existing packages", async () => {
|
||||
const pkg = makePackageDoc({
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: "publishers:owner-org",
|
||||
});
|
||||
|
||||
await expect(
|
||||
assertCanGenerateChangelogPreviewInternalHandler(
|
||||
makePackagePreviewAccessCtx({
|
||||
actorUserId: "users:viewer",
|
||||
pkg,
|
||||
}) as never,
|
||||
{ actorUserId: "users:viewer", name: "demo-plugin" },
|
||||
),
|
||||
).rejects.toThrow("Forbidden");
|
||||
});
|
||||
|
||||
it("allows direct owners to generate package changelog previews", async () => {
|
||||
await expect(
|
||||
assertCanGenerateChangelogPreviewInternalHandler(
|
||||
makePackagePreviewAccessCtx({
|
||||
actorUserId: "users:owner",
|
||||
pkg: makePackageDoc({ ownerUserId: "users:owner" }),
|
||||
}) as never,
|
||||
{ actorUserId: "users:owner", name: "demo-plugin" },
|
||||
),
|
||||
).resolves.toMatchObject({ ok: true, latestReleaseId: expect.anything() });
|
||||
});
|
||||
|
||||
it.each(["owner", "admin", "publisher"] as const)(
|
||||
"allows org %s members to generate package changelog previews",
|
||||
async (membershipRole) => {
|
||||
const pkg = makePackageDoc({
|
||||
ownerUserId: "users:creator",
|
||||
ownerPublisherId: "publishers:owner-org",
|
||||
});
|
||||
|
||||
await expect(
|
||||
assertCanGenerateChangelogPreviewInternalHandler(
|
||||
makePackagePreviewAccessCtx({
|
||||
actorUserId: "users:publisher-member",
|
||||
pkg,
|
||||
membershipRole,
|
||||
}) as never,
|
||||
{ actorUserId: "users:publisher-member", name: "demo-plugin" },
|
||||
),
|
||||
).resolves.toMatchObject({ ok: true, latestReleaseId: expect.anything() });
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["admin", "moderator"] as const)(
|
||||
"allows platform %s users to generate package changelog previews",
|
||||
async (actorRole) => {
|
||||
await expect(
|
||||
assertCanGenerateChangelogPreviewInternalHandler(
|
||||
makePackagePreviewAccessCtx({
|
||||
actorUserId: "users:staff",
|
||||
actorRole,
|
||||
pkg: makePackageDoc({ ownerUserId: "users:owner" }),
|
||||
}) as never,
|
||||
{ actorUserId: "users:staff", name: "demo-plugin" },
|
||||
),
|
||||
).resolves.toMatchObject({ ok: true, latestReleaseId: expect.anything() });
|
||||
},
|
||||
);
|
||||
|
||||
it("pages eligible plugins by immutable creation time without scanning the catalog client-side", async () => {
|
||||
const ctx = makePluginExportCtx([
|
||||
makeDigest("updated-old", {
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
readArtifactReportStatus,
|
||||
appendPackageModerationEventLog,
|
||||
} from "./lib/artifactModeration";
|
||||
import { generatePackageChangelogPreview } from "./lib/changelog";
|
||||
import { sha256Hex } from "./lib/clawpack";
|
||||
import {
|
||||
ACTIVITY_TREND_DAYS,
|
||||
@@ -477,6 +478,7 @@ const internalRefs = internal as unknown as {
|
||||
getByNameForViewerInternal: unknown;
|
||||
getPackageByIdInternal: unknown;
|
||||
getReleaseByIdInternal: unknown;
|
||||
assertCanGenerateChangelogPreviewInternal: unknown;
|
||||
getReleaseByPackageAndVersionInternal: unknown;
|
||||
getPackageReleaseScanBackfillBatchInternal: unknown;
|
||||
listVersionsForViewerInternal: unknown;
|
||||
@@ -2905,6 +2907,29 @@ export const getManageContext = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const assertCanGenerateChangelogPreviewInternal = internalQuery({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
name: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const actor = await ctx.db.get(args.actorUserId);
|
||||
if (!actor || actor.deletedAt || actor.deactivatedAt) {
|
||||
throw new ConvexError("Unauthorized");
|
||||
}
|
||||
|
||||
const pkg = await getPackageByNormalizedName(ctx, args.name);
|
||||
if (!pkg || pkg.softDeletedAt) return { ok: true as const, latestReleaseId: null };
|
||||
if (pkg.family === "skill") throw new ConvexError("Forbidden");
|
||||
const result = { ok: true as const, latestReleaseId: pkg.latestReleaseId ?? null };
|
||||
if (actor.role === "admin" || actor.role === "moderator") return result;
|
||||
|
||||
const canPublish = await viewerCanAccessPackageOwner(ctx, pkg, args.actorUserId);
|
||||
if (!canPublish) throw new ConvexError("Forbidden");
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
export const canDeleteVersions = query({
|
||||
args: {
|
||||
name: v.string(),
|
||||
@@ -8953,6 +8978,36 @@ export const finalizePackagePublishAttemptInternal = internalAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const generateChangelogPreview: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
name: v.string(),
|
||||
family: v.union(v.literal("code-plugin"), v.literal("bundle-plugin")),
|
||||
version: v.string(),
|
||||
readmeText: v.string(),
|
||||
filePaths: v.optional(v.array(v.string())),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireUserFromAction(ctx);
|
||||
const name = normalizePackageName(args.name);
|
||||
const version = assertPackageVersion(args.family, args.version);
|
||||
const authorizedPreview = await runQueryRef<{
|
||||
ok: true;
|
||||
latestReleaseId?: Id<"packageReleases"> | null;
|
||||
}>(ctx, internalRefs.packages.assertCanGenerateChangelogPreviewInternal, {
|
||||
actorUserId: userId,
|
||||
name,
|
||||
});
|
||||
const changelog = await generatePackageChangelogPreview(ctx, {
|
||||
name,
|
||||
version,
|
||||
readmeText: args.readmeText,
|
||||
filePaths: args.filePaths,
|
||||
latestReleaseId: authorizedPreview.latestReleaseId ?? null,
|
||||
});
|
||||
return { changelog };
|
||||
},
|
||||
});
|
||||
|
||||
export const publishPackageForTrustedPublisherInternal = internalAction({
|
||||
args: {
|
||||
publishTokenId: v.id("packagePublishTokens"),
|
||||
|
||||
@@ -32,6 +32,7 @@ vi.mock("sonner", () => ({
|
||||
|
||||
const generateUploadUrl = vi.fn();
|
||||
const publishRelease = vi.fn();
|
||||
const generateChangelogPreview = vi.fn();
|
||||
const fetchMock = vi.fn();
|
||||
const writeTextMock = vi.fn();
|
||||
const useAuthStatusMock = vi.fn();
|
||||
@@ -42,7 +43,10 @@ const originalFetch = globalThis.fetch;
|
||||
vi.mock("convex/react", () => ({
|
||||
ConvexReactClient: class {},
|
||||
useMutation: () => generateUploadUrl,
|
||||
useAction: () => publishRelease,
|
||||
useAction: (fn: unknown) => {
|
||||
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
|
||||
return name === "packages:generateChangelogPreview" ? generateChangelogPreview : publishRelease;
|
||||
},
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
}));
|
||||
|
||||
@@ -134,6 +138,7 @@ describe("plugins publish route", () => {
|
||||
beforeEach(() => {
|
||||
generateUploadUrl.mockReset();
|
||||
publishRelease.mockReset();
|
||||
generateChangelogPreview.mockReset();
|
||||
fetchMock.mockReset();
|
||||
writeTextMock.mockReset();
|
||||
useAuthStatusMock.mockReset();
|
||||
@@ -163,6 +168,7 @@ describe("plugins publish route", () => {
|
||||
});
|
||||
generateUploadUrl.mockResolvedValue("https://upload.local");
|
||||
publishRelease.mockResolvedValue({ ok: true, packageId: "pkg:1", releaseId: "rel:1" });
|
||||
generateChangelogPreview.mockResolvedValue({ changelog: "- Updated package." });
|
||||
fetchMock.mockImplementation(async (_url: string, init?: RequestInit) => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -582,6 +588,193 @@ describe("plugins publish route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-fills a package changelog preview from the uploaded README", async () => {
|
||||
useSearchMock.mockReturnValue({
|
||||
ownerHandle: "vintageayu",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
nextVersion: "1.2.4",
|
||||
sourceRepo: "openclaw/demo-plugin",
|
||||
});
|
||||
generateChangelogPreview.mockResolvedValueOnce({
|
||||
changelog: "- Added README-driven install guidance.",
|
||||
});
|
||||
|
||||
renderPublishRoute();
|
||||
|
||||
const packageJson = withRelativePath(
|
||||
new File(
|
||||
[
|
||||
makeCodePluginPackageJson({
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.2.4",
|
||||
repository: "https://github.com/openclaw/demo-plugin.git",
|
||||
}),
|
||||
],
|
||||
"package.json",
|
||||
{ type: "application/json" },
|
||||
),
|
||||
"demo-plugin/package.json",
|
||||
);
|
||||
const manifest = withRelativePath(
|
||||
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
|
||||
"demo-plugin/openclaw.plugin.json",
|
||||
);
|
||||
const readme = withRelativePath(
|
||||
new File(["# Demo Plugin\n\nAdds install guidance."], "README.md", {
|
||||
type: "text/markdown",
|
||||
}),
|
||||
"demo-plugin/README.md",
|
||||
);
|
||||
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, readme] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(generateChangelogPreview).toHaveBeenCalledWith({
|
||||
name: "demo-plugin",
|
||||
family: "code-plugin",
|
||||
version: "1.2.4",
|
||||
readmeText: "# Demo Plugin\n\nAdds install guidance.",
|
||||
filePaths: ["package.json", "openclaw.plugin.json", "README.md"],
|
||||
});
|
||||
expect(
|
||||
(
|
||||
screen.getByPlaceholderText(
|
||||
"Describe what changed in this release...",
|
||||
) as HTMLTextAreaElement
|
||||
).value,
|
||||
).toBe("- Added README-driven install guidance.");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let a stale package changelog preview overwrite manual text", async () => {
|
||||
useSearchMock.mockReturnValue({
|
||||
ownerHandle: "vintageayu",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
nextVersion: "1.2.4",
|
||||
sourceRepo: "openclaw/demo-plugin",
|
||||
});
|
||||
let resolvePreview: ((value: { changelog: string }) => void) | undefined;
|
||||
generateChangelogPreview.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvePreview = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
renderPublishRoute();
|
||||
|
||||
const packageJson = withRelativePath(
|
||||
new File(
|
||||
[
|
||||
makeCodePluginPackageJson({
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.2.4",
|
||||
repository: "https://github.com/openclaw/demo-plugin.git",
|
||||
}),
|
||||
],
|
||||
"package.json",
|
||||
{ type: "application/json" },
|
||||
),
|
||||
"demo-plugin/package.json",
|
||||
);
|
||||
const manifest = withRelativePath(
|
||||
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
|
||||
"demo-plugin/openclaw.plugin.json",
|
||||
);
|
||||
const readme = withRelativePath(
|
||||
new File(["# Demo Plugin\n\nAdds install guidance."], "README.md", {
|
||||
type: "text/markdown",
|
||||
}),
|
||||
"demo-plugin/README.md",
|
||||
);
|
||||
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, readme] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(generateChangelogPreview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const changelog = screen.getByPlaceholderText(
|
||||
"Describe what changed in this release...",
|
||||
) as HTMLTextAreaElement;
|
||||
fireEvent.change(changelog, { target: { value: "Manual release notes." } });
|
||||
resolvePreview?.({ changelog: "- Stale generated text." });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(changelog.value).toBe("Manual release notes.");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not apply a package changelog preview after preview inputs change", async () => {
|
||||
useSearchMock.mockReturnValue({
|
||||
ownerHandle: "vintageayu",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
nextVersion: "1.2.4",
|
||||
sourceRepo: "openclaw/demo-plugin",
|
||||
});
|
||||
let resolvePreview: ((value: { changelog: string }) => void) | undefined;
|
||||
generateChangelogPreview.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvePreview = resolve;
|
||||
}),
|
||||
);
|
||||
generateChangelogPreview.mockImplementationOnce(() => new Promise(() => {}));
|
||||
|
||||
renderPublishRoute();
|
||||
|
||||
const packageJson = withRelativePath(
|
||||
new File(
|
||||
[
|
||||
makeCodePluginPackageJson({
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.2.4",
|
||||
repository: "https://github.com/openclaw/demo-plugin.git",
|
||||
}),
|
||||
],
|
||||
"package.json",
|
||||
{ type: "application/json" },
|
||||
),
|
||||
"demo-plugin/package.json",
|
||||
);
|
||||
const manifest = withRelativePath(
|
||||
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
|
||||
"demo-plugin/openclaw.plugin.json",
|
||||
);
|
||||
const readme = withRelativePath(
|
||||
new File(["# Demo Plugin\n\nAdds install guidance."], "README.md", {
|
||||
type: "text/markdown",
|
||||
}),
|
||||
"demo-plugin/README.md",
|
||||
);
|
||||
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, readme] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(generateChangelogPreview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Version"), {
|
||||
target: { value: "1.2.5" },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(generateChangelogPreview).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
const changelog = screen.getByPlaceholderText(
|
||||
"Describe what changed in this release...",
|
||||
) as HTMLTextAreaElement;
|
||||
resolvePreview?.({ changelog: "- Stale generated text." });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(changelog.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("sends explicit empty catalog metadata when it is cleared on a plugin version publish", async () => {
|
||||
useSearchMock.mockReturnValue({
|
||||
ownerHandle: "vintageayu",
|
||||
|
||||
@@ -68,6 +68,7 @@ export const Route = createFileRoute("/plugins/publish")({
|
||||
|
||||
const apiRefs = api as unknown as {
|
||||
packages: {
|
||||
generateChangelogPreview: unknown;
|
||||
publishRelease: unknown;
|
||||
};
|
||||
};
|
||||
@@ -248,6 +249,15 @@ export function PublishPluginRoute() {
|
||||
const publishRelease = useAction(apiRefs.packages.publishRelease as never) as unknown as (args: {
|
||||
payload: unknown;
|
||||
}) => Promise<unknown>;
|
||||
const generateChangelogPreview = useAction(
|
||||
apiRefs.packages.generateChangelogPreview as never,
|
||||
) as unknown as (args: {
|
||||
name: string;
|
||||
family: "code-plugin" | "bundle-plugin";
|
||||
version: string;
|
||||
readmeText: string;
|
||||
filePaths?: string[];
|
||||
}) => Promise<{ changelog: string }>;
|
||||
const [family, setFamily] = useState<"code-plugin" | "bundle-plugin">(
|
||||
search.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
|
||||
);
|
||||
@@ -256,6 +266,9 @@ export function PublishPluginRoute() {
|
||||
const [ownerHandle, setOwnerHandle] = useState(search.ownerHandle ?? "");
|
||||
const [version, setVersion] = useState(search.nextVersion ?? "0.1.0");
|
||||
const [changelog, setChangelog] = useState("");
|
||||
const changelogTouchedRef = useRef(false);
|
||||
const changelogRequestRef = useRef(0);
|
||||
const changelogKeyRef = useRef<string | null>(null);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [suggestedCategories, setSuggestedCategories] = useState<string[]>();
|
||||
const [topics, setTopics] = useState("");
|
||||
@@ -436,6 +449,9 @@ export function PublishPluginRoute() {
|
||||
setFiles(filtered.files);
|
||||
setPackageSourceKind(sourceKind);
|
||||
setIgnoredPaths(nextIgnoredPaths);
|
||||
changelogRequestRef.current += 1;
|
||||
changelogKeyRef.current = null;
|
||||
if (!changelogTouchedRef.current) setChangelog("");
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
setSubmittedPlugin(null);
|
||||
@@ -460,6 +476,9 @@ export function PublishPluginRoute() {
|
||||
setDetectedPrefillFields([]);
|
||||
setCodePluginFieldIssues([]);
|
||||
setSuggestedCategories(undefined);
|
||||
changelogRequestRef.current += 1;
|
||||
changelogKeyRef.current = null;
|
||||
if (!changelogTouchedRef.current) setChangelog("");
|
||||
// Without this reset the README warning Badge keeps showing the previous
|
||||
// package's relative-asset findings until the next pick's async scan
|
||||
// finishes — which is misleading both while no package is selected and
|
||||
@@ -498,6 +517,66 @@ export function PublishPluginRoute() {
|
||||
}
|
||||
}, [existing]);
|
||||
|
||||
useEffect(() => {
|
||||
changelogRequestRef.current += 1;
|
||||
changelogKeyRef.current = null;
|
||||
if (!changelogTouchedRef.current) setChangelog("");
|
||||
}, [family, name, normalizedPaths, version]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showChangelogField) return;
|
||||
if (isMetadataLocked) return;
|
||||
if (changelogTouchedRef.current) return;
|
||||
if (changelog.trim()) return;
|
||||
const packageName = name.trim();
|
||||
const packageVersion = version.trim();
|
||||
if (!packageName || !semver.valid(packageVersion)) return;
|
||||
const readmeFile = findReadmeFile(files);
|
||||
if (!readmeFile) return;
|
||||
|
||||
const key = [
|
||||
packageName,
|
||||
family,
|
||||
packageVersion,
|
||||
readmeFile.name,
|
||||
readmeFile.size,
|
||||
readmeFile.lastModified,
|
||||
normalizedPaths.join("\0"),
|
||||
].join(":");
|
||||
if (changelogKeyRef.current === key) return;
|
||||
changelogKeyRef.current = key;
|
||||
|
||||
const requestId = ++changelogRequestRef.current;
|
||||
void readmeFile
|
||||
.text()
|
||||
.then((text) => {
|
||||
if (changelogRequestRef.current !== requestId) return null;
|
||||
return generateChangelogPreview({
|
||||
name: packageName,
|
||||
family,
|
||||
version: packageVersion,
|
||||
readmeText: text.slice(0, 20_000),
|
||||
filePaths: normalizedPaths,
|
||||
});
|
||||
})
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
if (changelogRequestRef.current !== requestId || changelogTouchedRef.current) return;
|
||||
setChangelog(result.changelog);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [
|
||||
changelog,
|
||||
family,
|
||||
files,
|
||||
generateChangelogPreview,
|
||||
isMetadataLocked,
|
||||
name,
|
||||
normalizedPaths,
|
||||
showChangelogField,
|
||||
version,
|
||||
]);
|
||||
|
||||
if (isAuthLoading) {
|
||||
return <PublishFormSkeleton />;
|
||||
}
|
||||
@@ -872,7 +951,11 @@ export function PublishPluginRoute() {
|
||||
rows={4}
|
||||
value={changelog}
|
||||
disabled={metadataDisabled}
|
||||
onChange={(event) => setChangelog(event.target.value)}
|
||||
onChange={(event) => {
|
||||
changelogTouchedRef.current = true;
|
||||
changelogRequestRef.current += 1;
|
||||
setChangelog(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user