diff --git a/convex/lib/changelog.test.ts b/convex/lib/changelog.test.ts index fdb2b4af..a7bcd3ee 100644 --- a/convex/lib/changelog.test.ts +++ b/convex/lib/changelog.test.ts @@ -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"); + }); }); diff --git a/convex/lib/changelog.ts b/convex/lib/changelog.ts index e7e060aa..da15fd2e 100644 --- a/convex/lib/changelog.ts +++ b/convex/lib/changelog.ts @@ -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 { + 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, }; diff --git a/convex/packages.public.test.ts b/convex/packages.public.test.ts index f0a63d2f..ad6cc43b 100644 --- a/convex/packages.public.test.ts +++ b/convex/packages.public.test.ts @@ -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 | 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", { diff --git a/convex/packages.ts b/convex/packages.ts index 67734b91..1ee19700 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -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 = 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"), diff --git a/src/__tests__/plugins-publish-route.test.tsx b/src/__tests__/plugins-publish-route.test.tsx index bdd47034..59dd179d 100644 --- a/src/__tests__/plugins-publish-route.test.tsx +++ b/src/__tests__/plugins-publish-route.test.tsx @@ -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[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", diff --git a/src/routes/plugins/publish.tsx b/src/routes/plugins/publish.tsx index a8e06618..fd58d31e 100644 --- a/src/routes/plugins/publish.tsx +++ b/src/routes/plugins/publish.tsx @@ -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; + 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(null); const [categories, setCategories] = useState([]); const [suggestedCategories, setSuggestedCategories] = useState(); 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 ; } @@ -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); + }} /> ) : null}