Skip to content

Commit 8dff82f

Browse files
committed
add script to generate PR from registry issues
1 parent 6dc5c17 commit 8dff82f

File tree

5 files changed

+376
-0
lines changed

5 files changed

+376
-0
lines changed

.github/workflows/issue-pr.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: Issue to Pull Request
2+
3+
on:
4+
issues:
5+
types: [opened, edited]
6+
7+
jobs:
8+
start:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v3
12+
13+
- uses: pnpm/action-setup@v2
14+
with:
15+
version: 9.6.0
16+
cache: true # handles caching automatically
17+
18+
- uses: actions/setup-node@v3
19+
with:
20+
node-version: 18
21+
22+
- run: pnpm install --frozen-lockfile --prefer-offline
23+
- run: pnpm exec tsx scripts/actions/loader.ts ${{ github.token }} issue-pr

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,17 @@
4141
},
4242
"packageManager": "[email protected]",
4343
"dependencies": {
44+
"@actions/core": "^1.11.1",
45+
"@actions/github": "^6.0.1",
46+
"@actions/io": "^2.0.0",
4447
"@babel/core": "^7.22.1",
4548
"@changesets/changelog-github": "^0.4.8",
4649
"@changesets/cli": "^2.26.1",
4750
"@commitlint/cli": "^20.1.0",
4851
"@commitlint/config-conventional": "^17.6.3",
4952
"@ianvs/prettier-plugin-sort-imports": "^3.7.2",
5053
"@manypkg/cli": "^0.20.0",
54+
"@type-challenges/octokit-create-pull-request": "^0.1.9",
5155
"@typescript-eslint/parser": "^5.59.7",
5256
"autoprefixer": "^10.4.14",
5357
"concurrently": "^8.0.1",
@@ -79,6 +83,7 @@
7983
},
8084
"pnpm": {
8185
"overrides": {
86+
"esbuild": "0.25.11",
8287
"@types/react": "19.2.2",
8388
"@types/react-dom": "19.2.2"
8489
}

scripts/actions/issue-pr.ts

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
import { PushCommit } from "@type-challenges/octokit-create-pull-request";
2+
3+
import { Action, Context, Github } from "./types";
4+
5+
export const getOthers = <A, B>(condition: boolean, a: A, b: B): A | B => (condition ? a : b);
6+
7+
const action: Action = async (github, context, core) => {
8+
const owner = context.repo.owner;
9+
const repo = context.repo.repo;
10+
const payload = context.payload || {};
11+
const issue = payload.issue;
12+
const no = context.issue.number;
13+
14+
if (!issue) return;
15+
16+
const labels: string[] = (issue.labels || []).map((i: any) => i && i.name).filter(Boolean);
17+
18+
// add to registry directory
19+
if (isRegistryDirectoryIssue(labels)) {
20+
const body = normalizeBody(issue.body || "");
21+
22+
let registryIssue: RegistryIssue;
23+
24+
try {
25+
registryIssue = parseRegistryIssue(body);
26+
registryIssue.logo = await resolveLogoContent(registryIssue.logo, core);
27+
} catch (error) {
28+
const message = error instanceof Error ? error.message : "Unknown error";
29+
core.error(`Failed to parse registry issue: ${message}`);
30+
await updateComment(github, context, RegistryMessages.issue_invalid_reply);
31+
return;
32+
}
33+
34+
const { data: user } = await github.rest.users.getByUsername({
35+
username: issue.user.login,
36+
});
37+
38+
const registries = await readJsonFile<RegistriesMap>(github, owner, repo, REGISTRIES_JSON_PATH);
39+
const directory = await readJsonFile<RegistryDirectoryEntry[]>(github, owner, repo, REGISTRY_DIRECTORY_JSON_PATH);
40+
41+
const registryAlreadyExists = registries.hasOwnProperty(registryIssue.name);
42+
43+
registries[registryIssue.name] = registryIssue.url;
44+
const nextDirectory = updateRegistryDirectory(directory, registryIssue);
45+
46+
const files: Record<string, string> = {
47+
[REGISTRIES_JSON_PATH]: `${JSON.stringify(registries, null, 2)}\n`,
48+
[REGISTRY_DIRECTORY_JSON_PATH]: `${JSON.stringify(nextDirectory, null, 2)}\n`,
49+
};
50+
51+
const userEmail = `${user.id}+${user.login}@users.noreply.github.com`;
52+
const commitMessage = `${registryAlreadyExists ? "chore" : "feat"}(registry): ${registryAlreadyExists ? "update" : "add"} ${registryIssue.name}`;
53+
54+
const { data: pulls } = await github.rest.pulls.list({
55+
owner,
56+
repo,
57+
state: "open",
58+
});
59+
60+
const existing_pull = pulls.find((i) => i.user?.login === "github-actions[bot]" && i.title.startsWith(`#${no} `));
61+
62+
await PushCommit(github as any, {
63+
owner,
64+
repo,
65+
base: "main",
66+
head: `pulls/${no}`,
67+
changes: {
68+
files,
69+
commit: commitMessage,
70+
author: {
71+
name: `${user.name || user.id || user.login}`,
72+
email: userEmail,
73+
},
74+
},
75+
fresh: !existing_pull,
76+
});
77+
78+
const replyBody = (prNumber: number, status: "created" | "updated") => {
79+
const key: RegistryReplyKey = status === "created" ? "issue_created_reply" : "issue_updated_reply";
80+
return RegistryMessages[key].replace("{0}", prNumber.toString());
81+
};
82+
83+
if (existing_pull) {
84+
await updateComment(github, context, replyBody(existing_pull.number, "updated"));
85+
} else {
86+
const { data: pr } = await github.rest.pulls.create({
87+
owner,
88+
repo,
89+
base: "main",
90+
head: `pulls/${no}`,
91+
title: `#${no} - ${registryIssue.name} registry directory update`,
92+
body: RegistryMessages.pr_body.replace(/{no}/g, no.toString()).replace(/{name}/g, registryIssue.name),
93+
labels: ["auto-generated", "registry", "directory"],
94+
});
95+
96+
await github.rest.issues.addLabels({
97+
owner,
98+
repo,
99+
issue_number: pr.number,
100+
labels: ["auto-generated", "registry", "directory"],
101+
});
102+
103+
await updateComment(github, context, replyBody(pr.number, "created"));
104+
}
105+
106+
return;
107+
}
108+
109+
};
110+
111+
112+
const REGISTRIES_JSON_PATH = "apps/v4/public/r/registries.json";
113+
const REGISTRY_DIRECTORY_JSON_PATH = "apps/v4/registry/directory.json";
114+
115+
const RegistryMessages = {
116+
issue_created_reply: "Thanks! PR #{0} has been created to update the registry directory.",
117+
issue_updated_reply: "Thanks! PR #{0} has been updated with the latest registry directory changes.",
118+
issue_invalid_reply: "Failed to parse the issue. Please ensure all required fields are filled in.",
119+
pr_body:
120+
"This is an auto-generated PR that updates the registry directory for issue #{no}.\n\n" +
121+
"- Registry: {name}\n" +
122+
"- Source issue: #{no}",
123+
} as const;
124+
125+
type RegistryReplyKey = keyof Pick<typeof RegistryMessages, "issue_created_reply" | "issue_updated_reply">;
126+
127+
type RegistriesMap = Record<string, string>;
128+
129+
type RegistryDirectoryEntry = {
130+
name: string;
131+
homepage: string;
132+
url: string;
133+
description: string;
134+
logo: string;
135+
};
136+
137+
type RegistryIssue = {
138+
name: string;
139+
url: string;
140+
homepage: string;
141+
description: string;
142+
logo: string;
143+
};
144+
145+
function isRegistryDirectoryIssue(labels: string[]) {
146+
return ["registry", "directory"].every((label) => labels.includes(label));
147+
}
148+
149+
function normalizeBody(body: string) {
150+
return body.replace(/\r\n/g, "\n");
151+
}
152+
153+
function parseRegistryIssue(body: string): RegistryIssue {
154+
const name = ensureField(extractSection(body, "Name"), "Name");
155+
const url = ensureField(extractSection(body, "URL"), "URL");
156+
const homepage = ensureField(extractSection(body, "Homepage"), "Homepage");
157+
const description = ensureField(extractSection(body, "Description"), "Description");
158+
const logoRaw = ensureField(extractSection(body, "Logo"), "Logo");
159+
160+
const normalizedName = name.startsWith("@") ? name : `@${name}`;
161+
162+
return {
163+
name: normalizedName,
164+
url: url.trim(),
165+
homepage: homepage.trim(),
166+
description: description.trim(),
167+
logo: normalizeLogoValue(logoRaw),
168+
};
169+
}
170+
171+
function ensureField(value: string | null, field: string) {
172+
if (!value || !value.trim()) throw new Error(`Missing "${field}" in the issue body.`);
173+
return value.trim();
174+
}
175+
176+
function extractSection(body: string, heading: string) {
177+
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
178+
const regex = new RegExp(`###\\s+${escapedHeading}\\s*\\n([\\s\\S]*?)(?=\\n###\\s+|\\n-\\s*\\[|$)`, "i");
179+
const match = body.match(regex);
180+
if (!match) return null;
181+
return match[1].trim();
182+
}
183+
184+
function stripCodeFence(value: string) {
185+
const trimmed = value.trim();
186+
const codeFenceRegex = /^```[a-z0-9-]*\n([\s\S]*?)\n```$/i;
187+
const match = trimmed.match(codeFenceRegex);
188+
if (match && match[1]) {
189+
return match[1].trim();
190+
}
191+
return trimmed;
192+
}
193+
194+
function normalizeLogoValue(value: string) {
195+
const unwrapped = stripCodeFence(value);
196+
const markdownImageMatch = unwrapped.match(/!\[[^\]]*\]\(([^)]+)\)/);
197+
if (markdownImageMatch && markdownImageMatch[1]) {
198+
return markdownImageMatch[1].trim();
199+
}
200+
return unwrapped.trim();
201+
}
202+
203+
async function resolveLogoContent(value: string, core: typeof import("@actions/core")): Promise<string> {
204+
const trimmed = value.trim();
205+
206+
if (isProbablyUrl(trimmed)) {
207+
try {
208+
const fetchFn = ((globalThis as unknown as { fetch?: (input: any, init?: any) => Promise<any> }).fetch);
209+
210+
if (!fetchFn) {
211+
throw new Error("Global fetch is not available in this runtime.");
212+
}
213+
214+
const response = await fetchFn(trimmed);
215+
216+
if (!response || typeof response.ok !== "boolean") {
217+
throw new Error("Unexpected response from fetch.");
218+
}
219+
220+
if (!response.ok) {
221+
throw new Error(`Request failed with status ${response.status}`);
222+
}
223+
224+
const headers = response.headers && typeof response.headers.get === "function" ? response.headers : null;
225+
const contentType = headers?.get("content-type") || "";
226+
if (!contentType.toLowerCase().includes("image/svg")) {
227+
throw new Error(`Expected SVG content but received "${contentType}"`);
228+
}
229+
230+
if (typeof response.text !== "function") {
231+
throw new Error("Response.text() is not available.");
232+
}
233+
234+
const svg = await response.text();
235+
return typeof svg === "string" ? svg.trim() : "";
236+
} catch (error) {
237+
const message = error instanceof Error ? error.message : "Unknown error";
238+
throw new Error(`Unable to download logo SVG: ${message}`);
239+
}
240+
}
241+
242+
return trimmed;
243+
}
244+
245+
function isProbablyUrl(value: string) {
246+
return /^https?:\/\//i.test(value);
247+
}
248+
249+
async function readJsonFile<T>(github: Github, owner: string, repo: string, path: string): Promise<T> {
250+
const { data } = await github.rest.repos.getContent({ owner, repo, path });
251+
252+
if (Array.isArray(data) || data.type !== "file" || !("content" in data)) {
253+
throw new Error(`Unable to read JSON content from ${path}`);
254+
}
255+
256+
const decoded = Buffer.from(data.content, data.encoding as BufferEncoding).toString("utf8");
257+
258+
try {
259+
return JSON.parse(decoded) as T;
260+
} catch (error) {
261+
throw new Error(`Failed to parse JSON from ${path}: ${(error as Error).message}`);
262+
}
263+
}
264+
265+
function updateRegistryDirectory(directory: RegistryDirectoryEntry[], data: RegistryIssue) {
266+
const nextDirectory = [...directory];
267+
const entry: RegistryDirectoryEntry = {
268+
name: data.name,
269+
homepage: data.homepage,
270+
url: data.url,
271+
description: data.description,
272+
logo: data.logo,
273+
};
274+
275+
const existingIndex = nextDirectory.findIndex((item) => item.name === data.name);
276+
277+
if (existingIndex >= 0) {
278+
nextDirectory[existingIndex] = entry;
279+
} else {
280+
nextDirectory.push(entry);
281+
}
282+
283+
return nextDirectory;
284+
}
285+
286+
async function updateComment(github: Github, context: Context, body: string) {
287+
const { data: comments } = await github.rest.issues.listComments({
288+
issue_number: context.issue.number,
289+
owner: context.repo.owner,
290+
repo: context.repo.repo,
291+
});
292+
293+
const existing_comment = comments.find((i) => i.user?.login === "github-actions[bot]");
294+
295+
if (existing_comment) {
296+
return await github.rest.issues.updateComment({
297+
comment_id: existing_comment.id,
298+
issue_number: context.issue.number,
299+
owner: context.repo.owner,
300+
repo: context.repo.repo,
301+
body,
302+
});
303+
} else {
304+
return await github.rest.issues.createComment({
305+
issue_number: context.issue.number,
306+
owner: context.repo.owner,
307+
repo: context.repo.repo,
308+
body,
309+
});
310+
}
311+
}
312+
313+
export default action;
314+
315+
export { parseRegistryIssue };

scripts/actions/loader.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import * as io from "@actions/io";
2+
import * as core from "@actions/core";
3+
import { context, getOctokit } from "@actions/github";
4+
5+
process.on("unhandledRejection", handleError);
6+
main().catch(handleError);
7+
8+
async function main(): Promise<void> {
9+
const token = process.argv[2];
10+
const fnName = process.argv[3];
11+
const github = getOctokit(token);
12+
13+
const module = await import(`./${fnName}.ts`);
14+
if (typeof module.default !== "function") {
15+
throw new Error(`Action "${fnName}" does not export a default function.`);
16+
}
17+
18+
await module.default(github, context, core, io);
19+
}
20+
21+
function handleError(err: any): void {
22+
console.error(err);
23+
core.setFailed(`Unhandled error: ${err}`);
24+
process.exit(1);
25+
}

0 commit comments

Comments
 (0)