diff --git a/.github/workflows/issue-pr.yml b/.github/workflows/issue-pr.yml
new file mode 100644
index 00000000000..a5adf7a750a
--- /dev/null
+++ b/.github/workflows/issue-pr.yml
@@ -0,0 +1,23 @@
+name: Issue to Pull Request
+
+on:
+ issues:
+ types: [opened, edited]
+
+jobs:
+ start:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+
+ - uses: pnpm/action-setup@v2
+ with:
+ version: 9.6.0
+ cache: true # handles caching automatically
+
+ - uses: actions/setup-node@v3
+ with:
+ node-version: 18
+
+ - run: pnpm install --frozen-lockfile --prefer-offline
+ - run: pnpm exec tsx scripts/actions/loader.ts ${{ github.token }} issue-pr
\ No newline at end of file
diff --git a/apps/v4/public/r/registries.json b/apps/v4/public/r/registries.json
index b852641d9f0..da02f0f72a9 100644
--- a/apps/v4/public/r/registries.json
+++ b/apps/v4/public/r/registries.json
@@ -86,4 +86,4 @@
"@hextaui": "https://hextaui.com/r/{name}.json",
"@taki": "https://taki-ui.com/r/{name}.json",
"@square-ui": "https://square.lndev.me/registry/{name}.json"
-}
+}
\ No newline at end of file
diff --git a/apps/v4/registry/directory.json b/apps/v4/registry/directory.json
index c28d1c12f89..df06a0645a4 100644
--- a/apps/v4/registry/directory.json
+++ b/apps/v4/registry/directory.json
@@ -454,4 +454,4 @@
"description": "Expertly crafted blocks, components & themes for shadcn/ui.",
"logo": ""
}
-]
+]
\ No newline at end of file
diff --git a/package.json b/package.json
index 0428a1302b8..66ee4e7414f 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,9 @@
},
"packageManager": "pnpm@9.0.6",
"dependencies": {
+ "@actions/core": "^1.11.1",
+ "@actions/github": "^6.0.1",
+ "@actions/io": "^2.0.0",
"@babel/core": "^7.22.1",
"@changesets/changelog-github": "^0.4.8",
"@changesets/cli": "^2.26.1",
@@ -48,6 +51,7 @@
"@commitlint/config-conventional": "^17.6.3",
"@ianvs/prettier-plugin-sort-imports": "^3.7.2",
"@manypkg/cli": "^0.20.0",
+ "@type-challenges/octokit-create-pull-request": "^0.1.9",
"@typescript-eslint/parser": "^5.59.7",
"autoprefixer": "^10.4.14",
"concurrently": "^8.0.1",
@@ -79,6 +83,7 @@
},
"pnpm": {
"overrides": {
+ "esbuild": "0.25.11",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2"
}
diff --git a/scripts/actions/issue-pr.ts b/scripts/actions/issue-pr.ts
new file mode 100644
index 00000000000..640cdd57aa7
--- /dev/null
+++ b/scripts/actions/issue-pr.ts
@@ -0,0 +1,315 @@
+import { PushCommit } from "@type-challenges/octokit-create-pull-request";
+
+import { Action, Context, Github } from "./types";
+
+export const getOthers = (condition: boolean, a: A, b: B): A | B => (condition ? a : b);
+
+const action: Action = async (github, context, core) => {
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const payload = context.payload || {};
+ const issue = payload.issue;
+ const no = context.issue.number;
+
+ if (!issue) return;
+
+ const labels: string[] = (issue.labels || []).map((i: any) => i && i.name).filter(Boolean);
+
+ // add to registry directory
+ if (isRegistryDirectoryIssue(labels)) {
+ const body = normalizeBody(issue.body || "");
+
+ let registryIssue: RegistryIssue;
+
+ try {
+ registryIssue = parseRegistryIssue(body);
+ registryIssue.logo = await resolveLogoContent(registryIssue.logo, core);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unknown error";
+ core.error(`Failed to parse registry issue: ${message}`);
+ await updateComment(github, context, RegistryMessages.issue_invalid_reply);
+ return;
+ }
+
+ const { data: user } = await github.rest.users.getByUsername({
+ username: issue.user.login,
+ });
+
+ const registries = await readJsonFile(github, owner, repo, REGISTRIES_JSON_PATH);
+ const directory = await readJsonFile(github, owner, repo, REGISTRY_DIRECTORY_JSON_PATH);
+
+ const registryAlreadyExists = registries.hasOwnProperty(registryIssue.name);
+
+ registries[registryIssue.name] = registryIssue.url;
+ const nextDirectory = updateRegistryDirectory(directory, registryIssue);
+
+ const files: Record = {
+ [REGISTRIES_JSON_PATH]: `${JSON.stringify(registries, null, 2)}\n`,
+ [REGISTRY_DIRECTORY_JSON_PATH]: `${JSON.stringify(nextDirectory, null, 2)}\n`,
+ };
+
+ const userEmail = `${user.id}+${user.login}@users.noreply.github.com`;
+ const commitMessage = `${registryAlreadyExists ? "chore" : "feat"}(registry): ${registryAlreadyExists ? "update" : "add"} ${registryIssue.name}`;
+
+ const { data: pulls } = await github.rest.pulls.list({
+ owner,
+ repo,
+ state: "open",
+ });
+
+ const existing_pull = pulls.find((i) => i.user?.login === "github-actions[bot]" && i.title.startsWith(`#${no} `));
+
+ await PushCommit(github as any, {
+ owner,
+ repo,
+ base: "main",
+ head: `pulls/${no}`,
+ changes: {
+ files,
+ commit: commitMessage,
+ author: {
+ name: `${user.name || user.id || user.login}`,
+ email: userEmail,
+ },
+ },
+ fresh: !existing_pull,
+ });
+
+ const replyBody = (prNumber: number, status: "created" | "updated") => {
+ const key: RegistryReplyKey = status === "created" ? "issue_created_reply" : "issue_updated_reply";
+ return RegistryMessages[key].replace("{0}", prNumber.toString());
+ };
+
+ if (existing_pull) {
+ await updateComment(github, context, replyBody(existing_pull.number, "updated"));
+ } else {
+ const { data: pr } = await github.rest.pulls.create({
+ owner,
+ repo,
+ base: "main",
+ head: `pulls/${no}`,
+ title: `#${no} - ${registryIssue.name} registry directory update`,
+ body: RegistryMessages.pr_body.replace(/{no}/g, no.toString()).replace(/{name}/g, registryIssue.name),
+ labels: ["auto-generated", "registry", "directory"],
+ });
+
+ await github.rest.issues.addLabels({
+ owner,
+ repo,
+ issue_number: pr.number,
+ labels: ["auto-generated", "registry", "directory"],
+ });
+
+ await updateComment(github, context, replyBody(pr.number, "created"));
+ }
+
+ return;
+ }
+
+};
+
+
+const REGISTRIES_JSON_PATH = "apps/v4/public/r/registries.json";
+const REGISTRY_DIRECTORY_JSON_PATH = "apps/v4/registry/directory.json";
+
+const RegistryMessages = {
+ issue_created_reply: "Thanks! PR #{0} has been created to update the registry directory.",
+ issue_updated_reply: "Thanks! PR #{0} has been updated with the latest registry directory changes.",
+ issue_invalid_reply: "Failed to parse the issue. Please ensure all required fields are filled in.",
+ pr_body:
+ "This is an auto-generated PR that updates the registry directory for issue #{no}.\n\n" +
+ "- Registry: {name}\n" +
+ "- Source issue: #{no}",
+} as const;
+
+type RegistryReplyKey = keyof Pick;
+
+type RegistriesMap = Record;
+
+type RegistryDirectoryEntry = {
+ name: string;
+ homepage: string;
+ url: string;
+ description: string;
+ logo: string;
+};
+
+type RegistryIssue = {
+ name: string;
+ url: string;
+ homepage: string;
+ description: string;
+ logo: string;
+};
+
+function isRegistryDirectoryIssue(labels: string[]) {
+ return ["registry", "directory"].every((label) => labels.includes(label));
+}
+
+function normalizeBody(body: string) {
+ return body.replace(/\r\n/g, "\n");
+}
+
+function parseRegistryIssue(body: string): RegistryIssue {
+ const name = ensureField(extractSection(body, "Name"), "Name");
+ const url = ensureField(extractSection(body, "URL"), "URL");
+ const homepage = ensureField(extractSection(body, "Homepage"), "Homepage");
+ const description = ensureField(extractSection(body, "Description"), "Description");
+ const logoRaw = ensureField(extractSection(body, "Logo"), "Logo");
+
+ const normalizedName = name.startsWith("@") ? name : `@${name}`;
+
+ return {
+ name: normalizedName,
+ url: url.trim(),
+ homepage: homepage.trim(),
+ description: description.trim(),
+ logo: normalizeLogoValue(logoRaw),
+ };
+}
+
+function ensureField(value: string | null, field: string) {
+ if (!value || !value.trim()) throw new Error(`Missing "${field}" in the issue body.`);
+ return value.trim();
+}
+
+function extractSection(body: string, heading: string) {
+ const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const regex = new RegExp(`###\\s+${escapedHeading}\\s*\\n([\\s\\S]*?)(?=\\n###\\s+|\\n-\\s*\\[|$)`, "i");
+ const match = body.match(regex);
+ if (!match) return null;
+ return match[1].trim();
+}
+
+function stripCodeFence(value: string) {
+ const trimmed = value.trim();
+ const codeFenceRegex = /^```[a-z0-9-]*\n([\s\S]*?)\n```$/i;
+ const match = trimmed.match(codeFenceRegex);
+ if (match && match[1]) {
+ return match[1].trim();
+ }
+ return trimmed;
+}
+
+function normalizeLogoValue(value: string) {
+ const unwrapped = stripCodeFence(value);
+ const markdownImageMatch = unwrapped.match(/!\[[^\]]*\]\(([^)]+)\)/);
+ if (markdownImageMatch && markdownImageMatch[1]) {
+ return markdownImageMatch[1].trim();
+ }
+ return unwrapped.trim();
+}
+
+async function resolveLogoContent(value: string, core: typeof import("@actions/core")): Promise {
+ const trimmed = value.trim();
+
+ if (isProbablyUrl(trimmed)) {
+ try {
+ const fetchFn = ((globalThis as unknown as { fetch?: (input: any, init?: any) => Promise }).fetch);
+
+ if (!fetchFn) {
+ throw new Error("Global fetch is not available in this runtime.");
+ }
+
+ const response = await fetchFn(trimmed);
+
+ if (!response || typeof response.ok !== "boolean") {
+ throw new Error("Unexpected response from fetch.");
+ }
+
+ if (!response.ok) {
+ throw new Error(`Request failed with status ${response.status}`);
+ }
+
+ const headers = response.headers && typeof response.headers.get === "function" ? response.headers : null;
+ const contentType = headers?.get("content-type") || "";
+ if (!contentType.toLowerCase().includes("image/svg")) {
+ throw new Error(`Expected SVG content but received "${contentType}"`);
+ }
+
+ if (typeof response.text !== "function") {
+ throw new Error("Response.text() is not available.");
+ }
+
+ const svg = await response.text();
+ return typeof svg === "string" ? svg.trim() : "";
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unknown error";
+ throw new Error(`Unable to download logo SVG: ${message}`);
+ }
+ }
+
+ return trimmed;
+}
+
+function isProbablyUrl(value: string) {
+ return /^https?:\/\//i.test(value);
+}
+
+async function readJsonFile(github: Github, owner: string, repo: string, path: string): Promise {
+ const { data } = await github.rest.repos.getContent({ owner, repo, path });
+
+ if (Array.isArray(data) || data.type !== "file" || !("content" in data)) {
+ throw new Error(`Unable to read JSON content from ${path}`);
+ }
+
+ const decoded = Buffer.from(data.content, data.encoding as BufferEncoding).toString("utf8");
+
+ try {
+ return JSON.parse(decoded) as T;
+ } catch (error) {
+ throw new Error(`Failed to parse JSON from ${path}: ${(error as Error).message}`);
+ }
+}
+
+function updateRegistryDirectory(directory: RegistryDirectoryEntry[], data: RegistryIssue) {
+ const nextDirectory = [...directory];
+ const entry: RegistryDirectoryEntry = {
+ name: data.name,
+ homepage: data.homepage,
+ url: data.url,
+ description: data.description,
+ logo: data.logo,
+ };
+
+ const existingIndex = nextDirectory.findIndex((item) => item.name === data.name);
+
+ if (existingIndex >= 0) {
+ nextDirectory[existingIndex] = entry;
+ } else {
+ nextDirectory.push(entry);
+ }
+
+ return nextDirectory;
+}
+
+async function updateComment(github: Github, context: Context, body: string) {
+ const { data: comments } = await github.rest.issues.listComments({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ });
+
+ const existing_comment = comments.find((i) => i.user?.login === "github-actions[bot]");
+
+ if (existing_comment) {
+ return await github.rest.issues.updateComment({
+ comment_id: existing_comment.id,
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body,
+ });
+ } else {
+ return await github.rest.issues.createComment({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body,
+ });
+ }
+}
+
+export default action;
+
+export { parseRegistryIssue };
\ No newline at end of file
diff --git a/scripts/actions/loader.ts b/scripts/actions/loader.ts
new file mode 100644
index 00000000000..8d4aaf0456f
--- /dev/null
+++ b/scripts/actions/loader.ts
@@ -0,0 +1,25 @@
+import * as io from "@actions/io";
+import * as core from "@actions/core";
+import { context, getOctokit } from "@actions/github";
+
+process.on("unhandledRejection", handleError);
+main().catch(handleError);
+
+async function main(): Promise {
+ const token = process.argv[2];
+ const fnName = process.argv[3];
+ const github = getOctokit(token);
+
+ const module = await import(`./${fnName}.ts`);
+ if (typeof module.default !== "function") {
+ throw new Error(`Action "${fnName}" does not export a default function.`);
+ }
+
+ await module.default(github, context, core, io);
+}
+
+function handleError(err: any): void {
+ console.error(err);
+ core.setFailed(`Unhandled error: ${err}`);
+ process.exit(1);
+}
\ No newline at end of file
diff --git a/scripts/actions/types.ts b/scripts/actions/types.ts
new file mode 100644
index 00000000000..28583a3a0cc
--- /dev/null
+++ b/scripts/actions/types.ts
@@ -0,0 +1,8 @@
+
+import type IO from "@actions/io";
+import type Core from "@actions/core";
+import type { context, getOctokit } from "@actions/github";
+
+export type Context = typeof context;
+export type Github = ReturnType;
+export type Action = (github: Github, context: Context, core: typeof Core, io: typeof IO) => Promise;