mirror of
https://github.com/lucide-icons/lucide.git
synced 2026-11-10 13:18:25 +00:00
ci(metadata-suggestions): run on every PR update with cached suggestions (#5062)
* feat(ci): run metadata suggestions on every PR update with cached results - Run on opened/reopened/synchronize and queue runs per PR. - Cache the model's suggestions per icon name with actions/cache, so the OpenAI API is only called again for icons that are added or renamed. - Skip suggestions that were already posted instead of skipping the whole PR once it has a review. - Suggest only the values to append after the last array item instead of the whole array; empty and inline arrays still get the full block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSdUq9uFpjuQsoobCBqz4j * refactor(ci): key the suggestions cache on the PR's icon names Replace the separate restore/save steps and the `cache-updated` output with a single actions/cache step keyed on a hash of the changed icon files, so the API is only called again when icon names change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSdUq9uFpjuQsoobCBqz4j * style(ci): drop trailing blank line in suggest-metadata action Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSdUq9uFpjuQsoobCBqz4j * fix(ci): dedupe metadata suggestions by value instead of comment body The append-style suggestion includes the array's current last item, so comparing whole comment bodies reposted the same values after a contributor added a tag or applied part of a suggestion. Read the values back from the bot's earlier suggestion blocks per file and field, and exclude them like the current values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSdUq9uFpjuQsoobCBqz4j --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9f20b2b8f9
commit
1ea91c0b72
@@ -18,3 +18,5 @@ PULL_REQUEST_NUMBER=
|
||||
# REVIEWER=
|
||||
# Read icon JSON from the local checkout instead of the PR on GitHub (default: false)
|
||||
# USE_FILE_SYSTEM=true
|
||||
# Cache model suggestions per icon name in this file, so re-runs skip the API (default: no cache)
|
||||
# SUGGESTIONS_CACHE_FILE=.cache/suggestions.json
|
||||
|
||||
@@ -22,6 +22,26 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
# Cache the suggestions by the pull request's icon names, so the API is only
|
||||
# called again when icons are added, removed or renamed.
|
||||
- name: Get changed icon names
|
||||
id: icons
|
||||
shell: bash
|
||||
run: |
|
||||
hash=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PULL_REQUEST_NUMBER/files" --paginate \
|
||||
--jq '.[].filename | select(startswith("icons/") and endswith(".json"))' \
|
||||
| sort | sha256sum | cut -d ' ' -f 1)
|
||||
echo "hash=$hash" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: ${{ inputs.github-token }}
|
||||
PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number }}
|
||||
|
||||
- name: Cache suggestions
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ runner.temp }}/suggest-metadata/suggestions.json
|
||||
key: suggest-metadata-pr-${{ inputs.pull-request-number }}-${{ steps.icons.outputs.hash }}
|
||||
|
||||
- name: Generate metadata suggestions
|
||||
shell: bash
|
||||
run: node "$GITHUB_ACTION_PATH/suggestMetaData.mts"
|
||||
@@ -31,3 +51,4 @@ runs:
|
||||
PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number }}
|
||||
COMMIT_SHA: ${{ inputs.commit-sha }}
|
||||
OPENAI_API_MODEL: ${{ inputs.model }}
|
||||
SUGGESTIONS_CACHE_FILE: ${{ runner.temp }}/suggest-metadata/suggestions.json
|
||||
|
||||
@@ -36,6 +36,7 @@ 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_SYSTEM === 'true' || false;
|
||||
const cacheFile = process.env.SUGGESTIONS_CACHE_FILE;
|
||||
|
||||
const owner = 'lucide-icons';
|
||||
const repo = 'lucide';
|
||||
@@ -128,6 +129,12 @@ const metadataSchema = z.object({
|
||||
|
||||
type MetadataSuggestion = z.infer<typeof metadataSchema>;
|
||||
|
||||
// The action caches the suggestions under a key based on the pull request's
|
||||
// icon names, so a restored cache means the API doesn't need to be called again.
|
||||
const suggestionsCache: Record<string, MetadataSuggestion> = cacheFile
|
||||
? await fs.readFile(cacheFile, 'utf-8').then(JSON.parse, () => ({}))
|
||||
: {};
|
||||
|
||||
const { data: files } = await octokit.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
@@ -144,21 +151,33 @@ const { data: pullRequest } = await octokit.pulls.get({
|
||||
|
||||
const prDescription = (pullRequest.body || '').slice(0, 4000);
|
||||
|
||||
// TODO: Find a better way to check if the PR has been updated since the last review
|
||||
if (!debug) {
|
||||
const { data: reviews } = await octokit.pulls.listReviews({
|
||||
// The action runs on every update of the pull request. Collect the values that
|
||||
// were already suggested per file and field from the suggestion blocks of
|
||||
// earlier comments, so they are not suggested again. Comparing values rather
|
||||
// than whole comments keeps working when the surrounding array changes, e.g.
|
||||
// after a contributor adds a tag or applies part of a suggestion.
|
||||
const previousComments = (
|
||||
await octokit.paginate(octokit.pulls.listReviewComments, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullRequestNumber,
|
||||
});
|
||||
const hasUserReviews = reviews.some(review => review.user?.login === username);
|
||||
per_page: 100,
|
||||
})
|
||||
).filter((comment) => comment.user?.login === username);
|
||||
|
||||
if(hasUserReviews) {
|
||||
console.log(
|
||||
`Pull request #${pullRequestNumber} already has reviews from ${username}. Skipping...`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
const previouslySuggestedValues = new Map<string, string[]>();
|
||||
for (const { path: filePath, body } of previousComments) {
|
||||
const field = body.match(/^Suggested `([^`]+)`/)?.[1];
|
||||
const suggestion = body.match(/```suggestion\r?\n([\s\S]*?)\r?\n```/)?.[1];
|
||||
if (!field || !suggestion) continue;
|
||||
|
||||
const values = suggestion.split('\n').flatMap((line) => {
|
||||
const value = line.trim().match(/^("(?:[^"\\]|\\.)*"),?$/)?.[1];
|
||||
return value ? [JSON.parse(value) as string] : [];
|
||||
});
|
||||
|
||||
const key = `${filePath}:${field}`;
|
||||
previouslySuggestedValues.set(key, [...(previouslySuggestedValues.get(key) ?? []), ...values]);
|
||||
}
|
||||
|
||||
const changedFiles = files.filter(
|
||||
@@ -230,9 +249,21 @@ function getNewMetadataValues(
|
||||
}, []);
|
||||
}
|
||||
|
||||
// Render only the values to append to an existing multi-line array: the
|
||||
// current last item (now with a trailing comma) followed by the new values,
|
||||
// using the same indentation as the existing items.
|
||||
function buildAppendBlock(lastItemText: string, values: string[]) {
|
||||
const itemIndent = lastItemText.match(/^\s*/)![0];
|
||||
const lastItem = lastItemText.trimEnd();
|
||||
const items = values.map((value) => `${itemIndent}${JSON.stringify(value)}`).join(',\n');
|
||||
return `${lastItem.endsWith(',') ? lastItem : `${lastItem},`}\n${items}`;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// For non-empty multi-line arrays it also returns the line of the last item, so
|
||||
// new values can be suggested right after it.
|
||||
function findFieldBlock(lines: string[], field: MetadataField) {
|
||||
const startIdx = lines.findIndex((line) => line.trimStart().startsWith(`"${field}":`));
|
||||
if (startIdx === -1) return null;
|
||||
@@ -246,34 +277,19 @@ function findFieldBlock(lines: string[], field: MetadataField) {
|
||||
}
|
||||
|
||||
const trailingComma = lines[endIdx].trim().endsWith(',') ? ',' : '';
|
||||
return { startLine: startIdx + 1, endLine: endIdx + 1, trailingComma };
|
||||
const lastItemLine = endIdx - 1 > startIdx ? endIdx : undefined;
|
||||
return { startLine: startIdx + 1, endLine: endIdx + 1, lastItemLine, 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
|
||||
async function getSuggestion(
|
||||
iconName: string,
|
||||
currentMetadata: Record<MetadataField, string[]>,
|
||||
): Promise<MetadataSuggestion> {
|
||||
if (suggestionsCache[iconName]) {
|
||||
console.log(`Using cached suggestions for ${iconName}.`);
|
||||
return suggestionsCache[iconName];
|
||||
}
|
||||
|
||||
const metadata = JSON.parse(fileContent)
|
||||
|
||||
const currentMetadata = {
|
||||
tags: metadata.tags ?? [],
|
||||
categories: metadata.categories ?? [],
|
||||
"use-cases": metadata["use-cases"] ?? [],
|
||||
};
|
||||
|
||||
const input = [
|
||||
{
|
||||
role: 'user' as const,
|
||||
@@ -311,59 +327,113 @@ const suggestionsByFile = changedFiles.map(async ({ filename, raw_url }) => {
|
||||
},
|
||||
});
|
||||
|
||||
const suggested: MetadataSuggestion = metadataSchema.parse(JSON.parse(response.output_text));
|
||||
const suggested = metadataSchema.parse(JSON.parse(response.output_text));
|
||||
suggestionsCache[iconName] = suggested;
|
||||
|
||||
console.log(`Suggestions for ${iconName}:`, suggested);
|
||||
console.log(`Current metadata for ${iconName}:`, currentMetadata);
|
||||
return suggested;
|
||||
}
|
||||
|
||||
const lines = fileContent.split('\n');
|
||||
const chatGptQuery = `Suggest tags, categories and use-cases for a "${iconName}" icon in the Lucide icon library.`;
|
||||
const suggestionsByFile = await Promise.all(
|
||||
changedFiles.map(async ({ filename, raw_url }) => {
|
||||
const iconName = path.basename(filename, '.json');
|
||||
|
||||
// Build one inline GitHub suggestion per field. Values are deduped
|
||||
// case-insensitively, and tags already searchable through the icon name are omitted.
|
||||
const comments = METADATA_FIELDS.flatMap((field) => {
|
||||
const current: string[] = currentMetadata[field];
|
||||
const newValues = getNewMetadataValues(field, current, suggested[field], iconName);
|
||||
|
||||
if (newValues.length === 0) {
|
||||
console.log(`No new ${field} to suggest for ${iconName}. Skipping...`);
|
||||
return [];
|
||||
// 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 block = findFieldBlock(lines, field);
|
||||
if (!block) {
|
||||
console.log(`Could not locate "${field}" in ${filename}. Skipping...`);
|
||||
return [];
|
||||
}
|
||||
const metadata = JSON.parse(fileContent)
|
||||
|
||||
const suggestion = buildArrayBlock(field, [...current, ...newValues], block.trailingComma);
|
||||
const currentMetadata = {
|
||||
tags: metadata.tags ?? [],
|
||||
categories: metadata.categories ?? [],
|
||||
"use-cases": metadata["use-cases"] ?? [],
|
||||
};
|
||||
|
||||
const body = `Suggested \`${field}\` for the \`${iconName}\` icon.
|
||||
const suggested = await getSuggestion(iconName, currentMetadata);
|
||||
|
||||
console.log(`Suggestions for ${iconName}:`, suggested);
|
||||
console.log(`Current metadata for ${iconName}:`, currentMetadata);
|
||||
|
||||
return { filename, iconName, fileContent, currentMetadata, suggested };
|
||||
}),
|
||||
);
|
||||
|
||||
// Write the cache before any early exit below, so the action can save it.
|
||||
if (cacheFile) {
|
||||
await fs.mkdir(path.dirname(cacheFile), { recursive: true });
|
||||
await fs.writeFile(cacheFile, JSON.stringify(suggestionsCache, null, 2));
|
||||
}
|
||||
|
||||
const comments = suggestionsByFile.flatMap(
|
||||
({ filename, iconName, fileContent, currentMetadata, suggested }) => {
|
||||
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. Values are deduped
|
||||
// case-insensitively against the current and previously suggested values,
|
||||
// and tags already searchable through the icon name are omitted.
|
||||
return METADATA_FIELDS.flatMap((field) => {
|
||||
const current: string[] = currentMetadata[field];
|
||||
const previouslySuggested = previouslySuggestedValues.get(`${filename}:${field}`) ?? [];
|
||||
const newValues = getNewMetadataValues(
|
||||
field,
|
||||
[...current, ...previouslySuggested],
|
||||
suggested[field],
|
||||
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 [];
|
||||
}
|
||||
|
||||
// Append the new values after the last item when possible, so the
|
||||
// suggestion only shows what is added. Empty and inline arrays are
|
||||
// replaced as a whole.
|
||||
const suggestion = block.lastItemLine
|
||||
? buildAppendBlock(lines[block.lastItemLine - 1], newValues)
|
||||
: 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: ReviewComment = {
|
||||
path: filename,
|
||||
line: block.endLine,
|
||||
side: "RIGHT",
|
||||
body,
|
||||
};
|
||||
const comment: ReviewComment = {
|
||||
path: filename,
|
||||
line: block.lastItemLine ?? 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";
|
||||
}
|
||||
// Replaced multi-line arrays need a start anchor; appends and inline
|
||||
// arrays are single-line.
|
||||
if (!block.lastItemLine && block.endLine !== block.startLine) {
|
||||
comment.start_line = block.startLine;
|
||||
comment.start_side = "RIGHT";
|
||||
}
|
||||
|
||||
return [comment];
|
||||
});
|
||||
|
||||
return comments;
|
||||
})
|
||||
|
||||
const comments = (await Promise.all(suggestionsByFile)).flat()
|
||||
return [comment];
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (comments.length === 0) {
|
||||
console.log('No new metadata to suggest for any icons.');
|
||||
|
||||
@@ -2,11 +2,21 @@ name: Pull request metadata suggestions
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
paths:
|
||||
- 'icons/*.json'
|
||||
|
||||
permissions: {}
|
||||
|
||||
# Queue runs instead of cancelling them, so a run that already called the
|
||||
# OpenAI API can save its cache before the next run restores it.
|
||||
concurrency:
|
||||
group: metadata-suggestions-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
pull-request-metadata-suggestions:
|
||||
name: Pull Request Metadata Suggestions
|
||||
|
||||
Reference in New Issue
Block a user