From 1ea91c0b7217b89777874fba97c94399a7de425e Mon Sep 17 00:00:00 2001 From: Eric Fennis Date: Fri, 23 Oct 2026 18:04:30 +0200 Subject: [PATCH] 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 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 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 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 Claude-Session: https://claude.ai/code/session_01TSdUq9uFpjuQsoobCBqz4j --------- Co-authored-by: Claude --- .github/actions/suggest-metadata/.env.example | 2 + .github/actions/suggest-metadata/action.yml | 21 ++ .../suggest-metadata/suggestMetaData.mts | 218 ++++++++++++------ .../pull-request-metadata-suggestions.yml | 10 + 4 files changed, 177 insertions(+), 74 deletions(-) diff --git a/.github/actions/suggest-metadata/.env.example b/.github/actions/suggest-metadata/.env.example index d2789b9ac..a04ab87ff 100644 --- a/.github/actions/suggest-metadata/.env.example +++ b/.github/actions/suggest-metadata/.env.example @@ -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 diff --git a/.github/actions/suggest-metadata/action.yml b/.github/actions/suggest-metadata/action.yml index c59b7d111..74caaffc5 100644 --- a/.github/actions/suggest-metadata/action.yml +++ b/.github/actions/suggest-metadata/action.yml @@ -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 diff --git a/.github/actions/suggest-metadata/suggestMetaData.mts b/.github/actions/suggest-metadata/suggestMetaData.mts index 8f1a1541a..665188577 100644 --- a/.github/actions/suggest-metadata/suggestMetaData.mts +++ b/.github/actions/suggest-metadata/suggestMetaData.mts @@ -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; +// 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 = 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(); +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, +): Promise { + 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.'); diff --git a/.github/workflows/pull-request-metadata-suggestions.yml b/.github/workflows/pull-request-metadata-suggestions.yml index 31787c3da..e6fa9b58e 100644 --- a/.github/workflows/pull-request-metadata-suggestions.yml +++ b/.github/workflows/pull-request-metadata-suggestions.yml @@ -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