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:
Eric Fennis
2026-10-23 18:04:30 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 9f20b2b8f9
commit 1ea91c0b72
4 changed files with 177 additions and 74 deletions
@@ -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