mirror of
https://github.com/lucide-icons/lucide.git
synced 2026-11-10 13:18:25 +00:00
chore(suggest-tags): Update metadata suggestion script (#4462)
* feat(metadata): implement pull request metadata suggestions workflow and replace suggestTags script * Switch to GPT 5 * Update comment description * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
parent
db63e66333
commit
283e4b2805
+4
-4
@@ -1,4 +1,4 @@
|
||||
name: Pull request tags suggestions
|
||||
name: Pull request metadata suggestions
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
@@ -6,8 +6,8 @@ on:
|
||||
- 'icons/*.json'
|
||||
|
||||
jobs:
|
||||
pull-request-tags-suggestions:
|
||||
name: Pull Request Tags Suggestions
|
||||
pull-request-metadata-suggestions:
|
||||
name: Pull Request Metadata Suggestions
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate comment markup
|
||||
run: node ./scripts/suggestTags.mts
|
||||
run: node ./scripts/suggestMetaData.mts
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
PULL_REQUEST_NUMBER: ${{ github.event.number }}
|
||||
+2
-2
@@ -25,8 +25,8 @@
|
||||
"generate:sponsors": "node scripts/updateSponsors.mts",
|
||||
"generate:contributors": "node ./scripts/updateContributors.mts icons/*.svg",
|
||||
"generate:nextJSAliases": "node ./scripts/generateNextJSAliases.mts",
|
||||
"suggest:tags": "node ./scripts/suggestTags.mts",
|
||||
"suggest:tags:watch": "node --env-file .env --watch ./scripts/suggestTags.mts ",
|
||||
"suggest:metadata": "node ./scripts/suggestMetaData.mts",
|
||||
"suggest:metadata:watch": "node --env-file .env --watch ./scripts/suggestMetaData.mts",
|
||||
"postinstall": "husky install",
|
||||
"lint:es": "eslint .",
|
||||
"lint:format": "prettier \"**/*.{js,mjs,ts,jsx,tsx,html,css,scss,json,yml,yaml}\" --check",
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import 'dotenv/config'
|
||||
import OpenAI from "openai";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { zodTextFormat } from "openai/helpers/zod";
|
||||
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import z from "zod";
|
||||
|
||||
// Resolve repo paths relative to this script so they work no matter which
|
||||
// directory the script is invoked from (the workflow runs it from the repo root).
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.join(scriptDir, "..");
|
||||
const iconsDir = path.join(repoRoot, "icons");
|
||||
const categoriesDir = path.join(repoRoot, "categories");
|
||||
|
||||
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
||||
const pullRequestNumber = Number(process.env.PULL_REQUEST_NUMBER);
|
||||
const username = process.env.REVIEWER ?? 'github-actions[bot]';
|
||||
const commitSha = process.env.COMMIT_SHA ?? "HEAD";
|
||||
const useFileSystem = process.env.USE_FILE_SYSYEM === 'true' || false;
|
||||
|
||||
const owner = 'lucide-icons';
|
||||
const repo = 'lucide';
|
||||
|
||||
const METADATA_FIELDS = ['tags', 'categories', 'use-cases'] as const;
|
||||
type MetadataField = (typeof METADATA_FIELDS)[number];
|
||||
|
||||
// Load the allowed categories (name + human-readable title) straight from the
|
||||
// `categories/` directory so we can both validate suggestions and give the
|
||||
// model a description of every category.
|
||||
async function loadCategories() {
|
||||
const files = (await fs.readdir(categoriesDir)).filter((file) => file.endsWith('.json'));
|
||||
|
||||
const categories = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const { title } = JSON.parse(await fs.readFile(path.join(categoriesDir, file), 'utf-8'));
|
||||
return { name: path.basename(file, '.json'), title };
|
||||
}),
|
||||
);
|
||||
|
||||
return categories.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// Sample a handful of well-populated icons to feed the model as few-shot
|
||||
// examples so its suggestions match the repository's house style. Candidates
|
||||
// are spread evenly across the (alphabetically sorted) icon set and read
|
||||
// sequentially to keep the number of open file handles bounded.
|
||||
async function loadReferenceExamples(count = 8) {
|
||||
const fileNames = (await fs.readdir(iconsDir)).filter((file) => file.endsWith('.json')).sort();
|
||||
const candidateCount = Math.min(fileNames.length, count * 8);
|
||||
const step = Math.max(1, Math.floor(fileNames.length / candidateCount));
|
||||
|
||||
const examples: Record<string, unknown>[] = [];
|
||||
for (let i = 0; i < fileNames.length && examples.length < count; i += step) {
|
||||
try {
|
||||
const metadata = JSON.parse(await fs.readFile(path.join(iconsDir, fileNames[i]), 'utf-8'));
|
||||
const isWellPopulated = METADATA_FIELDS.every(
|
||||
(field) => Array.isArray(metadata[field]) && metadata[field].length > 0,
|
||||
);
|
||||
|
||||
if (isWellPopulated) {
|
||||
examples.push({
|
||||
name: path.basename(fileNames[i], '.json'),
|
||||
tags: metadata.tags,
|
||||
categories: metadata.categories,
|
||||
'use-cases': metadata['use-cases'],
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore files that fail to parse.
|
||||
}
|
||||
}
|
||||
|
||||
return examples;
|
||||
}
|
||||
|
||||
const categories = await loadCategories();
|
||||
const categoryNames = categories.map((category) => category.name);
|
||||
const referenceExamples = await loadReferenceExamples();
|
||||
|
||||
const metadataSchema = z.object({
|
||||
tags: z.array(z.string()),
|
||||
categories: z.array(z.enum(categoryNames as [string, ...string[]])),
|
||||
"use-cases": z.array(z.string()),
|
||||
})
|
||||
|
||||
type MetadataSuggestion = z.infer<typeof metadataSchema>;
|
||||
|
||||
const { data: files } = await octokit.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullRequestNumber,
|
||||
});
|
||||
|
||||
const { data: reviews } = await octokit.pulls.listReviews({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullRequestNumber,
|
||||
query: `in:body author:github-actions[bot]`,
|
||||
});
|
||||
|
||||
// Get the PR description so the model can ground its suggestions in the
|
||||
// author's stated intent for the icon. Truncated to keep the prompt small.
|
||||
const { data: pullRequest } = await octokit.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullRequestNumber,
|
||||
});
|
||||
|
||||
const prDescription = (pullRequest.body || '').slice(0, 4000);
|
||||
|
||||
const hasUserReviews = reviews.some(review => review.user?.login === username);
|
||||
|
||||
// TODO: Find a better way to check if the PR has been updated since the last review
|
||||
if(hasUserReviews) {
|
||||
console.log(`Pull request #${pullRequestNumber} already has reviews from ${username}. Skipping...`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const changedFiles = files.filter(
|
||||
({filename}) => filename.startsWith('icons/') && filename.includes('.json')
|
||||
)
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
console.log('No changed icons found');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const client = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
});
|
||||
|
||||
const categoriesContext = categories.map(({ name, title }) => `- ${name}: ${title}`).join('\n');
|
||||
|
||||
// Render an array property exactly as it should appear in the metadata JSON,
|
||||
// preserving the repo's 2-space indentation and the original trailing comma.
|
||||
function buildArrayBlock(field: MetadataField, values: string[], trailingComma: string) {
|
||||
const indent = ' ';
|
||||
const itemIndent = ' ';
|
||||
|
||||
if (values.length === 0) {
|
||||
return `${indent}"${field}": []${trailingComma}`;
|
||||
}
|
||||
|
||||
const items = values.map((value) => `${itemIndent}${JSON.stringify(value)}`).join(',\n');
|
||||
return `${indent}"${field}": [\n${items}\n${indent}]${trailingComma}`;
|
||||
}
|
||||
|
||||
// Locate the line range of an array property in the raw file so we can anchor a
|
||||
// GitHub suggestion to it. Handles both inline (`"use-cases": []`) and
|
||||
// multi-line arrays, and reports whether the closing line has a trailing comma.
|
||||
function findFieldBlock(lines: string[], field: MetadataField) {
|
||||
const startIdx = lines.findIndex((line) => line.trimStart().startsWith(`"${field}":`));
|
||||
if (startIdx === -1) return null;
|
||||
|
||||
let endIdx = startIdx;
|
||||
if (!lines[startIdx].includes(']')) {
|
||||
for (let i = startIdx + 1; i < lines.length; i++) {
|
||||
endIdx = i;
|
||||
if (lines[i].trimStart().startsWith(']')) break;
|
||||
}
|
||||
}
|
||||
|
||||
const trailingComma = lines[endIdx].trim().endsWith(',') ? ',' : '';
|
||||
return { startLine: startIdx + 1, endLine: endIdx + 1, trailingComma };
|
||||
}
|
||||
|
||||
const suggestionsByFile = changedFiles.map(async ({ filename, raw_url }) => {
|
||||
const iconName = path.basename(filename, '.json');
|
||||
|
||||
// Read the icon's current metadata (the PR head version) first, so we can
|
||||
// both give it to the model as context and dedupe suggestions against it.
|
||||
let fileContent: string
|
||||
if (useFileSystem) {
|
||||
fileContent = await fs.readFile(path.join(repoRoot, filename), 'utf-8');
|
||||
} else {
|
||||
const fileGithubRequest = await octokit.request(`GET ${raw_url}`, {
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github.v3.raw',
|
||||
},
|
||||
});
|
||||
fileContent = fileGithubRequest.data
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(fileContent)
|
||||
|
||||
const currentMetadata = {
|
||||
tags: metadata.tags ?? [],
|
||||
categories: metadata.categories ?? [],
|
||||
"use-cases": metadata["use-cases"] ?? [],
|
||||
};
|
||||
|
||||
const input = `You are maintaining the metadata for the Lucide icon library. Suggest additional metadata for the \`${iconName}\` icon.
|
||||
|
||||
Guidelines:
|
||||
- tags: lowercase, single words, no spaces. Used for search. Never include the word "icon" or the icon's own name ("${iconName}").
|
||||
- categories: only use values from the allowed categories listed below. Lowercase. Keep them relevant to the icon.
|
||||
- use-cases: short lowercase phrases describing concrete situations the icon represents (e.g. "indicating a disabled webcam"). No trailing punctuation.
|
||||
Only suggest NEW values that build on the current metadata, and prefer quality over quantity.
|
||||
|
||||
Allowed categories:
|
||||
${categoriesContext}
|
||||
|
||||
Current metadata for "${iconName}":
|
||||
${JSON.stringify(currentMetadata, null, 2)}
|
||||
|
||||
Pull request description:
|
||||
${prDescription || '(no description provided)'}
|
||||
|
||||
Reference examples from existing icons:
|
||||
${JSON.stringify(referenceExamples, null, 2)}`;
|
||||
|
||||
const response = await client.responses.create({
|
||||
model: "gpt-5-mini",
|
||||
input,
|
||||
text: {
|
||||
format: zodTextFormat(metadataSchema, "metadata"),
|
||||
},
|
||||
});
|
||||
|
||||
const suggested: MetadataSuggestion = JSON.parse(response.output_text);
|
||||
|
||||
console.log(`Suggestions for ${iconName}:`, suggested);
|
||||
console.log(`Current metadata for ${iconName}:`, currentMetadata);
|
||||
|
||||
const lines = fileContent.split('\n');
|
||||
const chatGptQuery = `Suggest tags, categories and use-cases for a "${iconName}" icon in the Lucide icon library.`;
|
||||
|
||||
// Build one inline GitHub suggestion per field, deduped against the values
|
||||
// already present in the file.
|
||||
const comments = METADATA_FIELDS.flatMap((field) => {
|
||||
const current: string[] = currentMetadata[field];
|
||||
const newValues = suggested[field].filter((value) => !current.includes(value) && value !== iconName);
|
||||
|
||||
if (newValues.length === 0) {
|
||||
console.log(`No new ${field} to suggest for ${iconName}. Skipping...`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const block = findFieldBlock(lines, field);
|
||||
if (!block) {
|
||||
console.log(`Could not locate "${field}" in ${filename}. Skipping...`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const suggestion = buildArrayBlock(field, [...current, ...newValues], block.trailingComma);
|
||||
|
||||
const body = `Suggested \`${field}\` for the \`${iconName}\` icon.
|
||||
\`\`\`suggestion
|
||||
${suggestion}
|
||||
\`\`\`
|
||||
Want more ideas? [Ask ChatGPT](https://chatgpt.com/?q=${encodeURIComponent(chatGptQuery)})`;
|
||||
|
||||
const comment: Record<string, unknown> = {
|
||||
path: filename,
|
||||
line: block.endLine,
|
||||
side: "RIGHT",
|
||||
body,
|
||||
};
|
||||
|
||||
// Multi-line arrays need a start anchor; inline arrays are single-line.
|
||||
if (block.endLine !== block.startLine) {
|
||||
comment.start_line = block.startLine;
|
||||
comment.start_side = "RIGHT";
|
||||
}
|
||||
|
||||
return [comment];
|
||||
});
|
||||
|
||||
return comments;
|
||||
})
|
||||
|
||||
const comments = (await Promise.all(suggestionsByFile)).flat()
|
||||
|
||||
if (comments.length === 0) {
|
||||
console.log('No new metadata to suggest for any icons.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const reviewBody = `### 🤖 Metadata suggestions ✨
|
||||
I've asked ChatGPT for some suggestions for \`tags\`, \`categories\` and \`use-cases\`.
|
||||
Please review them and apply any that you find useful.
|
||||
`;
|
||||
|
||||
try {
|
||||
console.log({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullRequestNumber,
|
||||
body: reviewBody,
|
||||
event: "COMMENT",
|
||||
comments,
|
||||
commit_id: commitSha,
|
||||
})
|
||||
await octokit.pulls.createReview({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullRequestNumber,
|
||||
body: reviewBody,
|
||||
event: "COMMENT",
|
||||
comments,
|
||||
commit_id: commitSha,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating review:', error);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import 'dotenv/config'
|
||||
import OpenAI from "openai";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { zodTextFormat } from "openai/helpers/zod";
|
||||
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import z from "zod";
|
||||
import { type IconMetadata } from '../tools/build-icons/types.ts';
|
||||
|
||||
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
||||
const pullRequestNumber = Number(process.env.PULL_REQUEST_NUMBER);
|
||||
const username = process.env.REVIEWER ?? 'github-actions[bot]';
|
||||
const commitSha = process.env.COMMIT_SHA ?? "HEAD";
|
||||
const useFileSystem = process.env.USE_FILE_SYSYEM === 'true' || false;
|
||||
|
||||
const owner = 'lucide-icons';
|
||||
const repo = 'lucide';
|
||||
|
||||
const tagsSchema = z.object({
|
||||
tags: z.array(z.string()),
|
||||
})
|
||||
|
||||
const { data: files } = await octokit.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullRequestNumber,
|
||||
});
|
||||
|
||||
const { data: reviews } = await octokit.pulls.listReviews({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullRequestNumber,
|
||||
query: `in:body author:github-actions[bot]`,
|
||||
});
|
||||
|
||||
const hasUserReviews = reviews.some(review => review.user?.login === username);
|
||||
|
||||
// TODO: Find a better way to check if the PR has been updated since the last review
|
||||
if(hasUserReviews) {
|
||||
console.log(`Pull request #${pullRequestNumber} already has reviews from ${username}. Skipping...`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const changedFiles = files.filter(
|
||||
({filename}) => filename.startsWith('icons/') && filename.includes('.json')
|
||||
)
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
console.log('No changed icons found');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const client = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
});
|
||||
|
||||
const suggestionsByFile = changedFiles.map(async ({ filename, raw_url }) => {
|
||||
const filePath = filename.replace('.json', '');
|
||||
const iconName = filePath.split('/').pop();
|
||||
|
||||
const input = `Create a list of tags for a \`${iconName}\` icon. Don't include words like: 'icon' and preferably use single words.`;
|
||||
|
||||
const response = await client.responses.create({
|
||||
model: "gpt-4.1-nano",
|
||||
input,
|
||||
text: {
|
||||
format: zodTextFormat(tagsSchema, "tags"),
|
||||
},
|
||||
});
|
||||
|
||||
const { tags: suggestedTags }: IconMetadata = JSON.parse(response.output_text);
|
||||
|
||||
console.log(`Suggesting tags for ${iconName}:`, suggestedTags);
|
||||
|
||||
let fileContent: string
|
||||
|
||||
if( useFileSystem ) {
|
||||
const jsonFile = path.join(process.cwd(), filename);
|
||||
fileContent = await fs.readFile(jsonFile, 'utf-8') as unknown as string;
|
||||
} else {
|
||||
const fileGithubRequest = await octokit.request(`GET ${raw_url}`, {
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github.v3.raw',
|
||||
},
|
||||
});
|
||||
fileContent = fileGithubRequest.data
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(fileContent)
|
||||
|
||||
console.log(`Current tags for ${iconName}:`, metadata.tags || []);
|
||||
|
||||
const tagSuggestionsWithoutDuplicates = suggestedTags.filter((tag) => {
|
||||
return !metadata.tags?.includes(tag) && tag !== iconName;
|
||||
});
|
||||
|
||||
console.log(`Tag suggestions for ${iconName} without duplicates:`, tagSuggestionsWithoutDuplicates);
|
||||
|
||||
if (tagSuggestionsWithoutDuplicates.length === 0) {
|
||||
console.log(`No new tags to suggest for ${iconName}. Skipping...`);
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
// Find the startLine in the json file
|
||||
const startLine = fileContent.split('\n').findIndex((line) => line.includes('"tags":')) + 1;
|
||||
|
||||
const codeSuggestion = tagSuggestionsWithoutDuplicates.map((tag) => ` "${tag}"`).join(',\n');
|
||||
|
||||
const message = `Suggestions for the \`${iconName}\` icon.
|
||||
Try asking it your self if you want more suggestions. [Open ChatGPT](https://chatgpt.com/?q=${encodeURIComponent(input)})
|
||||
Here are the suggestions:
|
||||
\`\`\`suggestion\n "tags": [\n${codeSuggestion},`;
|
||||
|
||||
return {
|
||||
path: filename,
|
||||
line: startLine,
|
||||
body: message,
|
||||
side: "RIGHT"
|
||||
}
|
||||
})
|
||||
|
||||
const comments = (await Promise.all(suggestionsByFile)).filter(comment => comment !== null)
|
||||
|
||||
if (comments.length === 0) {
|
||||
console.log('No new tags to suggest for any icons.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullRequestNumber,
|
||||
body: `### 🤖 ChatGPT Tags suggestions ✨
|
||||
I've asked ChatGPT for some suggestions for tags.`,
|
||||
event: "COMMENT",
|
||||
comments,
|
||||
commit_id: commitSha,
|
||||
})
|
||||
await octokit.pulls.createReview({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullRequestNumber,
|
||||
body: `### 🤖 ChatGPT Tags suggestions ✨
|
||||
I've asked ChatGPT for some suggestions for tags.`,
|
||||
event: "COMMENT",
|
||||
comments,
|
||||
commit_id: commitSha,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating review:', error);
|
||||
process.exit(0);
|
||||
}
|
||||
Reference in New Issue
Block a user