From dc9b9354d12bd5205e0305f0821f848f20dcc6f7 Mon Sep 17 00:00:00 2001 From: yifan111 Date: Mon, 23 Mar 2026 19:14:51 +0800 Subject: [PATCH 01/11] feat: ai for ci diff action --- action.yml | 4 ++ dist/index.js | 168 +++++++++++++++++++++++++++++++++++++++++++-- src/ai-analysis.ts | 131 +++++++++++++++++++++++++++++++++++ src/index.ts | 69 ++++++++++++++++++- 4 files changed, 363 insertions(+), 9 deletions(-) create mode 100644 src/ai-analysis.ts diff --git a/action.yml b/action.yml index f4fc606..7adede6 100644 --- a/action.yml +++ b/action.yml @@ -20,6 +20,10 @@ inputs: required: false default: '' type: string + ai_model: + description: 'AI model to use for degradation analysis (e.g. claude-3-5-haiku-latest, gpt-4o-mini). Provider is auto-detected from the model name prefix.' + required: false + default: 'claude-3-5-haiku-latest' runs: using: 'node20' diff --git a/dist/index.js b/dist/index.js index 5b288d1..f363403 100644 --- a/dist/index.js +++ b/dist/index.js @@ -8945,8 +8945,8 @@ The following characters are not allowed in files that are uploaded due to limit let headers = {}; let status; let url; - const fetch = requestOptions.request && requestOptions.request.fetch || lib; - return fetch(requestOptions.url, Object.assign({ + const fetch1 = requestOptions.request && requestOptions.request.fetch || lib; + return fetch1(requestOptions.url, Object.assign({ method: requestOptions.method, body: requestOptions.body, headers: requestOptions.headers, @@ -45297,7 +45297,7 @@ The following characters are not allowed in files that are uploaded due to limit this.emit('terminated', error); } } - function fetch(input, init = {}) { + function fetch1(input, init = {}) { webidl.argumentLengthCheck(arguments, 1, { header: 'globalThis.fetch' }); @@ -45966,7 +45966,7 @@ The following characters are not allowed in files that are uploaded due to limit } } module.exports = { - fetch, + fetch: fetch1, Fetch, fetching, finalizeAndReportTiming @@ -96551,6 +96551,112 @@ var __webpack_exports__ = {}; await core.summary.write(); console.log('✅ Bundle size report card generated successfully'); } + function detectProvider(model) { + return model.toLowerCase().startsWith('claude') ? 'anthropic' : 'openai'; + } + async function callAnthropicAPI(prompt, token, model) { + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': token, + 'anthropic-version': '2023-06-01' + }, + body: JSON.stringify({ + model, + max_tokens: 2048, + messages: [ + { + role: 'user', + content: prompt + } + ] + }) + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`Anthropic API error ${response.status}: ${error}`); + } + const data = await response.json(); + return data.content[0].text; + } + async function callOpenAIAPI(prompt, token, model) { + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + model, + max_tokens: 2048, + messages: [ + { + role: 'user', + content: prompt + } + ] + }) + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`OpenAI API error ${response.status}: ${error}`); + } + const data = await response.json(); + return data.choices[0].message.content; + } + function buildPrompt(diffData) { + const MAX_CHARS = 50000; + let diffStr = JSON.stringify(diffData, null, 2); + if (diffStr.length > MAX_CHARS) diffStr = diffStr.substring(0, MAX_CHARS) + '\n... (truncated due to size)'; + return `You are a frontend performance expert analyzing a JavaScript bundle size diff report generated by Rsdoctor (a Webpack/Rspack bundle analyzer). + +Please analyze the following bundle diff JSON data and provide a concise report covering: + +1. **Size Regression Summary**: Which assets/chunks increased significantly in size +2. **Root Cause Analysis**: Likely causes of size increases based on the diff data +3. **Risk Assessment**: Overall severity — Low / Medium / High — with a brief justification +4. **Optimization Recommendations**: Specific, actionable steps to reduce the regressions + +Focus especially on: +- Assets or chunks with >5% or >10 KB size increase +- Newly added large assets or modules +- Changes to initial/entry chunks (highest priority) +- Potential duplicate dependencies + +Bundle diff data: +\`\`\`json +${diffStr} +\`\`\` + +Respond in concise GitHub-flavored Markdown suitable for a PR comment. If there are no regressions, say so clearly.`; + } + async function analyzeWithAI(diffJsonPath, token, model = 'claude-3-5-haiku-latest') { + if (!token) { + console.log('ℹ️ No AI token provided, skipping AI analysis'); + return null; + } + if (!external_fs_.existsSync(diffJsonPath)) { + console.log(`⚠️ Bundle diff JSON not found at ${diffJsonPath}, skipping AI analysis`); + return null; + } + try { + const diffData = JSON.parse(external_fs_.readFileSync(diffJsonPath, 'utf8')); + const prompt = buildPrompt(diffData); + const provider = detectProvider(model); + console.log(`🤖 Running AI analysis with ${provider} (${model})...`); + const analysis = 'anthropic' === provider ? await callAnthropicAPI(prompt, token, model) : await callOpenAIAPI(prompt, token, model); + console.log('✅ AI analysis completed'); + return { + analysis, + provider, + model + }; + } catch (error) { + console.warn(`⚠️ AI analysis failed: ${error}`); + return null; + } + } var external_util_ = __webpack_require__("util"); var out = __webpack_require__("./node_modules/.pnpm/fast-glob@3.3.3/node_modules/fast-glob/out/index.js"); var out_default = /*#__PURE__*/ __webpack_require__.n(out); @@ -96646,7 +96752,7 @@ var __webpack_exports__ = {}; } return pathParts[0] || 'root'; } - async function processSingleFile(fullPath, currentCommitHash, targetCommitHash, baselineUsedFallback, baselineLatestCommitHash) { + async function processSingleFile(fullPath, currentCommitHash, targetCommitHash, baselineUsedFallback, baselineLatestCommitHash, aiToken, aiModel) { const fileName = external_path_default().basename(fullPath); const relativePath = external_path_default().relative(process.cwd(), fullPath); const pathParts = relativePath.split(external_path_default().sep); @@ -96747,6 +96853,43 @@ var __webpack_exports__ = {}; } catch (e) { console.warn(`⚠️ Failed to upload diff html for ${projectName}: ${e}`); } + if (aiToken) try { + const diffJsonPath = external_path_default().join(tempOutDir, `rsdoctor-diff-${projectName}.json`); + const defaultDiffJsonPath = external_path_default().join(tempOutDir, 'rsdoctor-diff.json'); + try { + const cliEntry = require.resolve('@rsdoctor/cli', { + paths: [ + process.cwd() + ] + }); + const binCliEntry = external_path_default().join(external_path_default().dirname(external_path_default().dirname(cliEntry)), 'bin', 'rsdoctor'); + runRsdoctorViaNode(binCliEntry, [ + 'bundle-diff', + '--json', + `--baseline=${baselineJsonPath}`, + `--current=${fullPath}` + ]); + } catch (e) { + console.log(`⚠️ rsdoctor CLI (json) not found in node_modules: ${e}`); + try { + const shellCmd = `npx @rsdoctor/cli bundle-diff --json --baseline="${baselineJsonPath}" --current="${fullPath}"`; + console.log(`🛠️ Running rsdoctor --json via npx: ${shellCmd}`); + await execFileAsync('sh', [ + '-c', + shellCmd + ], { + cwd: tempOutDir + }); + } catch (npxError) { + console.log(`⚠️ npx approach (json) also failed: ${npxError}`); + } + } + if (external_fs_.existsSync(defaultDiffJsonPath) && !external_fs_.existsSync(diffJsonPath)) await external_fs_.promises.rename(defaultDiffJsonPath, diffJsonPath); + const resolvedJsonPath = external_fs_.existsSync(diffJsonPath) ? diffJsonPath : defaultDiffJsonPath; + report.aiAnalysis = await analyzeWithAI(resolvedJsonPath, aiToken, aiModel); + } catch (e) { + console.warn(`⚠️ Failed to generate JSON diff for AI analysis: ${e}`); + } } catch (e) { console.warn(`⚠️ rsdoctor bundle-diff failed for ${projectName}: ${e}`); } @@ -96769,6 +96912,9 @@ var __webpack_exports__ = {}; }); const currentCommitHash = githubService.getCurrentCommitHash(); console.log(`Current commit hash: ${currentCommitHash}`); + const aiToken = process.env.AI_TOKEN || ''; + const aiModel = (0, core.getInput)('ai_model') || 'claude-3-5-haiku-latest'; + if (aiToken) console.log(`🤖 AI analysis enabled (model: ${aiModel})`); let targetCommitHash = null; let baselineUsedFallback = false; let baselineLatestCommitHash; @@ -96825,7 +96971,7 @@ var __webpack_exports__ = {}; if (isDispatch) console.log('🔧 Processing workflow_dispatch event - uploading artifacts and comparing with baseline'); else console.log('📥 Detected pull request event - processing files'); for (const fullPath of matchedFiles){ - const report = await processSingleFile(fullPath, currentCommitHash, targetCommitHash, baselineUsedFallback, baselineLatestCommitHash); + const report = await processSingleFile(fullPath, currentCommitHash, targetCommitHash, baselineUsedFallback, baselineLatestCommitHash, aiToken, aiModel); projectReports.push(report); if (isDispatch) { const uploadResponse = await uploadArtifact(fullPath, currentCommitHash); @@ -96934,6 +97080,16 @@ var __webpack_exports__ = {}; } if (reportsWithChanges.length > 1) commentBody += '\n\n'; } + const reportsWithAI = projectReports.filter((r)=>r.aiAnalysis); + if (reportsWithAI.length > 0) { + commentBody += '
\n🤖 AI Degradation Analysis (Click to expand)\n\n'; + for (const report of reportsWithAI)if (report.aiAnalysis) { + if (reportsWithAI.length > 1) commentBody += `#### 📁 ${report.projectName}\n\n`; + commentBody += report.aiAnalysis.analysis + '\n\n'; + commentBody += `Analysis by ${report.aiAnalysis.model}\n\n`; + } + commentBody += '
\n\n'; + } commentBody += '*Generated by [Rsdoctor GitHub Action](https://rsdoctor.rs/guide/start/action)*'; try { await githubService.updateOrCreateComment(context.payload.pull_request.number, commentBody); diff --git a/src/ai-analysis.ts b/src/ai-analysis.ts new file mode 100644 index 0000000..a34d3f5 --- /dev/null +++ b/src/ai-analysis.ts @@ -0,0 +1,131 @@ +import * as fs from 'fs'; + +export interface AIAnalysisResult { + analysis: string; + provider: string; + model: string; +} + +function detectProvider(model: string): 'anthropic' | 'openai' { + return model.toLowerCase().startsWith('claude') ? 'anthropic' : 'openai'; +} + +async function callAnthropicAPI(prompt: string, token: string, model: string): Promise { + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': token, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model, + max_tokens: 2048, + messages: [{ role: 'user', content: prompt }], + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Anthropic API error ${response.status}: ${error}`); + } + + const data = await response.json() as any; + return data.content[0].text as string; +} + +async function callOpenAIAPI(prompt: string, token: string, model: string): Promise { + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + model, + max_tokens: 2048, + messages: [{ role: 'user', content: prompt }], + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`OpenAI API error ${response.status}: ${error}`); + } + + const data = await response.json() as any; + return data.choices[0].message.content as string; +} + +function buildPrompt(diffData: unknown): string { + // Truncate large diff data to avoid token limits (~50k chars) + const MAX_CHARS = 50000; + let diffStr = JSON.stringify(diffData, null, 2); + if (diffStr.length > MAX_CHARS) { + diffStr = diffStr.substring(0, MAX_CHARS) + '\n... (truncated due to size)'; + } + + return `You are a frontend performance expert analyzing a JavaScript bundle size diff report generated by Rsdoctor (a Webpack/Rspack bundle analyzer). + +Please analyze the following bundle diff JSON data and provide a concise report covering: + +1. **Size Regression Summary**: Which assets/chunks increased significantly in size +2. **Root Cause Analysis**: Likely causes of size increases based on the diff data +3. **Risk Assessment**: Overall severity — Low / Medium / High — with a brief justification +4. **Optimization Recommendations**: Specific, actionable steps to reduce the regressions + +Focus especially on: +- Assets or chunks with >5% or >10 KB size increase +- Newly added large assets or modules +- Changes to initial/entry chunks (highest priority) +- Potential duplicate dependencies + +Bundle diff data: +\`\`\`json +${diffStr} +\`\`\` + +Respond in concise GitHub-flavored Markdown suitable for a PR comment. If there are no regressions, say so clearly.`; +} + +/** + * Run AI degradation analysis on a bundle-diff JSON file. + * + * @param diffJsonPath Path to the JSON file produced by `rsdoctor bundle-diff --json` + * @param token AI API key (Anthropic or OpenAI) + * @param model Model name — auto-detects provider from prefix (default: claude-3-5-haiku-latest) + */ +export async function analyzeWithAI( + diffJsonPath: string, + token: string, + model = 'claude-3-5-haiku-latest', +): Promise { + if (!token) { + console.log('ℹ️ No AI token provided, skipping AI analysis'); + return null; + } + + if (!fs.existsSync(diffJsonPath)) { + console.log(`⚠️ Bundle diff JSON not found at ${diffJsonPath}, skipping AI analysis`); + return null; + } + + try { + const diffData: unknown = JSON.parse(fs.readFileSync(diffJsonPath, 'utf8')); + const prompt = buildPrompt(diffData); + const provider = detectProvider(model); + + console.log(`🤖 Running AI analysis with ${provider} (${model})...`); + + const analysis = + provider === 'anthropic' + ? await callAnthropicAPI(prompt, token, model) + : await callOpenAIAPI(prompt, token, model); + + console.log('✅ AI analysis completed'); + return { analysis, provider, model }; + } catch (error) { + console.warn(`⚠️ AI analysis failed: ${error}`); + return null; + } +} diff --git a/src/index.ts b/src/index.ts index cd4d5c2..63bfd33 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { uploadArtifact, hashPath } from './upload'; import { downloadArtifactByCommitHash } from './download'; import { GitHubService } from './github'; import { loadSizeData, generateSizeReport, parseRsdoctorData, generateBundleAnalysisReport, BundleAnalysis, generateProjectMarkdown, formatBytes, calculateDiff } from './report'; +import { analyzeWithAI, AIAnalysisResult } from './ai-analysis'; import path from 'path'; import * as fs from 'fs'; import { execFile } from 'child_process'; @@ -99,6 +100,7 @@ interface ProjectReport { diffHtmlArtifactId?: number; baselineUsedFallback?: boolean; baselineLatestCommitHash?: string; + aiAnalysis?: AIAnalysisResult | null; } function extractProjectName(filePath: string): string { @@ -139,6 +141,8 @@ async function processSingleFile( targetCommitHash: string | null, baselineUsedFallback?: boolean, baselineLatestCommitHash?: string, + aiToken?: string, + aiModel?: string, ): Promise { const fileName = path.basename(fullPath); const relativePath = path.relative(process.cwd(), fullPath); @@ -259,11 +263,49 @@ async function processSingleFile( console.warn(`⚠️ Failed to upload diff html for ${projectName}: ${e}`); } } + + // Generate JSON diff for AI analysis (requires @rsdoctor/cli >= 1.5.6-canary.0) + if (aiToken) { + try { + const diffJsonPath = path.join(tempOutDir, `rsdoctor-diff-${projectName}.json`); + const defaultDiffJsonPath = path.join(tempOutDir, 'rsdoctor-diff.json'); + + try { + const cliEntry = require.resolve('@rsdoctor/cli', { paths: [process.cwd()] }); + const binCliEntry = path.join(path.dirname(path.dirname(cliEntry)), 'bin', 'rsdoctor'); + runRsdoctorViaNode(binCliEntry, [ + 'bundle-diff', + '--json', + `--baseline=${baselineJsonPath}`, + `--current=${fullPath}`, + ]); + } catch (e) { + console.log(`⚠️ rsdoctor CLI (json) not found in node_modules: ${e}`); + try { + const shellCmd = `npx @rsdoctor/cli bundle-diff --json --baseline="${baselineJsonPath}" --current="${fullPath}"`; + console.log(`🛠️ Running rsdoctor --json via npx: ${shellCmd}`); + await execFileAsync('sh', ['-c', shellCmd], { cwd: tempOutDir }); + } catch (npxError) { + console.log(`⚠️ npx approach (json) also failed: ${npxError}`); + } + } + + // Rename default output to project-specific name to avoid collisions in monorepo + if (fs.existsSync(defaultDiffJsonPath) && !fs.existsSync(diffJsonPath)) { + await fs.promises.rename(defaultDiffJsonPath, diffJsonPath); + } + + const resolvedJsonPath = fs.existsSync(diffJsonPath) ? diffJsonPath : defaultDiffJsonPath; + report.aiAnalysis = await analyzeWithAI(resolvedJsonPath, aiToken, aiModel); + } catch (e) { + console.warn(`⚠️ Failed to generate JSON diff for AI analysis: ${e}`); + } + } } catch (e) { console.warn(`⚠️ rsdoctor bundle-diff failed for ${projectName}: ${e}`); } } - + return report; } @@ -293,7 +335,13 @@ async function processSingleFile( const currentCommitHash = githubService.getCurrentCommitHash(); console.log(`Current commit hash: ${currentCommitHash}`); - + + const aiToken = process.env.AI_TOKEN || ''; + const aiModel = getInput('ai_model') || 'claude-3-5-haiku-latest'; + if (aiToken) { + console.log(`🤖 AI analysis enabled (model: ${aiModel})`); + } + let targetCommitHash: string | null = null; let baselineUsedFallback = false; let baselineLatestCommitHash: string | undefined = undefined; @@ -385,7 +433,7 @@ async function processSingleFile( } for (const fullPath of matchedFiles) { - const report = await processSingleFile(fullPath, currentCommitHash, targetCommitHash, baselineUsedFallback, baselineLatestCommitHash); + const report = await processSingleFile(fullPath, currentCommitHash, targetCommitHash, baselineUsedFallback, baselineLatestCommitHash, aiToken, aiModel); projectReports.push(report); // For workflow_dispatch, also upload artifacts @@ -548,6 +596,21 @@ async function processSingleFile( } } + // Append AI degradation analysis if available (one section per project that has it) + const reportsWithAI = projectReports.filter(r => r.aiAnalysis); + if (reportsWithAI.length > 0) { + commentBody += '
\n🤖 AI Degradation Analysis (Click to expand)\n\n'; + for (const report of reportsWithAI) { + if (!report.aiAnalysis) continue; + if (reportsWithAI.length > 1) { + commentBody += `#### 📁 ${report.projectName}\n\n`; + } + commentBody += report.aiAnalysis.analysis + '\n\n'; + commentBody += `Analysis by ${report.aiAnalysis.model}\n\n`; + } + commentBody += '
\n\n'; + } + commentBody += '*Generated by [Rsdoctor GitHub Action](https://rsdoctor.rs/guide/start/action)*'; try { From 1306ce7abd97063603d0d1015a3709690a6144d9 Mon Sep 17 00:00:00 2001 From: yifan111 Date: Mon, 23 Mar 2026 19:18:32 +0800 Subject: [PATCH 02/11] test: for ai json diff --- examples/rsbuild-demo/src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/rsbuild-demo/src/App.tsx b/examples/rsbuild-demo/src/App.tsx index 079a9c2..359c9a9 100644 --- a/examples/rsbuild-demo/src/App.tsx +++ b/examples/rsbuild-demo/src/App.tsx @@ -12,12 +12,12 @@ const App = () => {

Start building amazing things with Rsbuild.

- + */}
From b49b9371f1232ab8b37d65cd7fdbc257ec92afae Mon Sep 17 00:00:00 2001 From: yifan111 Date: Mon, 23 Mar 2026 19:48:19 +0800 Subject: [PATCH 03/11] test: for ai json diff --- dist/index.js | 7 ++ examples/rsdoctor-diff.json | 170 ++++++++++++++++++++++++++++++++++++ src/index.ts | 11 +++ 3 files changed, 188 insertions(+) create mode 100644 examples/rsdoctor-diff.json diff --git a/dist/index.js b/dist/index.js index f363403..f1ff5a4 100644 --- a/dist/index.js +++ b/dist/index.js @@ -96893,6 +96893,13 @@ Respond in concise GitHub-flavored Markdown suitable for a PR comment. If there } catch (e) { console.warn(`⚠️ rsdoctor bundle-diff failed for ${projectName}: ${e}`); } + if (aiToken && !report.baseline && !report.aiAnalysis) try { + const fallbackDiffPath = external_path_default().resolve(__dirname, '..', 'examples', 'rsdoctor-diff.json'); + console.log(`ℹ️ No baseline found, falling back to example diff data for AI analysis`); + report.aiAnalysis = await analyzeWithAI(fallbackDiffPath, aiToken, aiModel); + } catch (e) { + console.warn(`⚠️ Fallback AI analysis failed: ${e}`); + } return report; } (async ()=>{ diff --git a/examples/rsdoctor-diff.json b/examples/rsdoctor-diff.json new file mode 100644 index 0000000..47b17d7 --- /dev/null +++ b/examples/rsdoctor-diff.json @@ -0,0 +1,170 @@ +{ + "assets": { + "all": { + "total": { + "size": { + "baseline": 285905, + "current": 286116 + }, + "count": { + "baseline": 8, + "current": 8 + }, + "percent": 0.07380073800738007, + "state": "UP" + } + }, + "js": { + "total": { + "size": { + "baseline": 284839, + "current": 285050 + }, + "count": { + "baseline": 5, + "current": 5 + }, + "percent": 0.07407693468942103, + "state": "UP" + }, + "initial": { + "size": { + "baseline": 284664, + "current": 284875 + }, + "count": { + "baseline": 4, + "current": 4 + }, + "percent": 0.07412247421521513, + "state": "UP" + } + }, + "css": { + "total": { + "size": { + "baseline": 430, + "current": 430 + }, + "count": { + "baseline": 1, + "current": 1 + }, + "percent": 0, + "state": "-" + }, + "initial": { + "size": { + "baseline": 430, + "current": 430 + }, + "count": { + "baseline": 1, + "current": 1 + }, + "percent": 0, + "state": "-" + } + }, + "imgs": { + "total": { + "size": { + "baseline": 0, + "current": 0 + }, + "count": { + "baseline": 0, + "current": 0 + }, + "percent": 0, + "state": "-" + } + }, + "html": { + "total": { + "size": { + "baseline": 636, + "current": 636 + }, + "count": { + "baseline": 2, + "current": 2 + }, + "percent": 0, + "state": "-" + } + }, + "media": { + "total": { + "size": { + "baseline": 0, + "current": 0 + }, + "count": { + "baseline": 0, + "current": 0 + }, + "percent": 0, + "state": "-" + } + }, + "fonts": { + "total": { + "size": { + "baseline": 0, + "current": 0 + }, + "count": { + "baseline": 0, + "current": 0 + }, + "percent": 0, + "state": "-" + } + }, + "others": { + "total": { + "size": { + "baseline": 0, + "current": 0 + }, + "count": { + "baseline": 0, + "current": 0 + }, + "percent": 0, + "state": "-" + } + } + }, + "modules": { + "added": [], + "removed": [], + "changed": [ + { + "path": "/Users/congzhe/Documents/2_workspace/rsdoctor/examples/rsbuild-minimal/src/App.tsx", + "size": { + "baseline": { + "sourceSize": 566, + "transformedSize": 0, + "parsedSize": 873, + "gzipSize": 415 + }, + "current": { + "sourceSize": 558, + "transformedSize": 0, + "parsedSize": 1078, + "gzipSize": 438 + } + }, + "percent": 23.482245131729666, + "state": "UP" + } + ] + }, + "packages": { + "added": [], + "removed": [], + "changed": [] + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 63bfd33..29e60bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -306,6 +306,17 @@ async function processSingleFile( } } + // Fallback: if AI token is set but no baseline was found, use example diff for AI analysis + if (aiToken && !report.baseline && !report.aiAnalysis) { + try { + const fallbackDiffPath = path.resolve(__dirname, '..', 'examples', 'rsdoctor-diff.json'); + console.log(`ℹ️ No baseline found, falling back to example diff data for AI analysis`); + report.aiAnalysis = await analyzeWithAI(fallbackDiffPath, aiToken, aiModel); + } catch (e) { + console.warn(`⚠️ Fallback AI analysis failed: ${e}`); + } + } + return report; } From 31adb7cbcaaa1fbe438dcf4c84cfe77b3bc3fcae Mon Sep 17 00:00:00 2001 From: yifan111 Date: Tue, 24 Mar 2026 13:00:36 +0800 Subject: [PATCH 04/11] test: for ai json diff --- dist/index.js | 1 + src/index.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/dist/index.js b/dist/index.js index f1ff5a4..1b6e27b 100644 --- a/dist/index.js +++ b/dist/index.js @@ -96920,6 +96920,7 @@ Respond in concise GitHub-flavored Markdown suitable for a PR comment. If there const currentCommitHash = githubService.getCurrentCommitHash(); console.log(`Current commit hash: ${currentCommitHash}`); const aiToken = process.env.AI_TOKEN || ''; + console.log('aiToken::::::::::', aiToken); const aiModel = (0, core.getInput)('ai_model') || 'claude-3-5-haiku-latest'; if (aiToken) console.log(`🤖 AI analysis enabled (model: ${aiModel})`); let targetCommitHash = null; diff --git a/src/index.ts b/src/index.ts index 29e60bc..d379364 100644 --- a/src/index.ts +++ b/src/index.ts @@ -348,6 +348,7 @@ async function processSingleFile( console.log(`Current commit hash: ${currentCommitHash}`); const aiToken = process.env.AI_TOKEN || ''; + console.log('aiToken::::::::::', aiToken); const aiModel = getInput('ai_model') || 'claude-3-5-haiku-latest'; if (aiToken) { console.log(`🤖 AI analysis enabled (model: ${aiModel})`); From 74994919f2423b63370b4ab376c7d532d3d1d7e0 Mon Sep 17 00:00:00 2001 From: yifan111 Date: Tue, 24 Mar 2026 14:52:02 +0800 Subject: [PATCH 05/11] test: for ai json diff --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 424c0d8..dc32334 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,8 @@ jobs: pnpm run build - name: Report Compressed Size + env: + AI_TOKEN: ${{ vars.AI_TOKEN }} uses: ./ with: github_token: ${{ secrets.GITHUB_TOKEN }} From eae76dd38b9d6339c8b768b9ae432e7f2332efb6 Mon Sep 17 00:00:00 2001 From: yifan111 Date: Tue, 24 Mar 2026 15:03:10 +0800 Subject: [PATCH 06/11] test: for ai json diff --- dist/index.js | 2 +- src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dist/index.js b/dist/index.js index 1b6e27b..14266cf 100644 --- a/dist/index.js +++ b/dist/index.js @@ -96893,7 +96893,7 @@ Respond in concise GitHub-flavored Markdown suitable for a PR comment. If there } catch (e) { console.warn(`⚠️ rsdoctor bundle-diff failed for ${projectName}: ${e}`); } - if (aiToken && !report.baseline && !report.aiAnalysis) try { + if (aiToken && !report.aiAnalysis) try { const fallbackDiffPath = external_path_default().resolve(__dirname, '..', 'examples', 'rsdoctor-diff.json'); console.log(`ℹ️ No baseline found, falling back to example diff data for AI analysis`); report.aiAnalysis = await analyzeWithAI(fallbackDiffPath, aiToken, aiModel); diff --git a/src/index.ts b/src/index.ts index d379364..e19c735 100644 --- a/src/index.ts +++ b/src/index.ts @@ -307,7 +307,7 @@ async function processSingleFile( } // Fallback: if AI token is set but no baseline was found, use example diff for AI analysis - if (aiToken && !report.baseline && !report.aiAnalysis) { + if (aiToken && !report.aiAnalysis) { try { const fallbackDiffPath = path.resolve(__dirname, '..', 'examples', 'rsdoctor-diff.json'); console.log(`ℹ️ No baseline found, falling back to example diff data for AI analysis`); From 76bbfbe0690a1fa1a495037e1df3bc8b9fa9ab50 Mon Sep 17 00:00:00 2001 From: yifan111 Date: Tue, 24 Mar 2026 15:09:51 +0800 Subject: [PATCH 07/11] test: for ai json diff --- dist/index.js | 15 +++++++++------ src/index.ts | 1 + 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/dist/index.js b/dist/index.js index 14266cf..6fc6230 100644 --- a/dist/index.js +++ b/dist/index.js @@ -96893,12 +96893,15 @@ Respond in concise GitHub-flavored Markdown suitable for a PR comment. If there } catch (e) { console.warn(`⚠️ rsdoctor bundle-diff failed for ${projectName}: ${e}`); } - if (aiToken && !report.aiAnalysis) try { - const fallbackDiffPath = external_path_default().resolve(__dirname, '..', 'examples', 'rsdoctor-diff.json'); - console.log(`ℹ️ No baseline found, falling back to example diff data for AI analysis`); - report.aiAnalysis = await analyzeWithAI(fallbackDiffPath, aiToken, aiModel); - } catch (e) { - console.warn(`⚠️ Fallback AI analysis failed: ${e}`); + if (aiToken && !report.aiAnalysis) { + console.log('Fallback:::::::::::::::::::'); + try { + const fallbackDiffPath = external_path_default().resolve(__dirname, '..', 'examples', 'rsdoctor-diff.json'); + console.log(`ℹ️ No baseline found, falling back to example diff data for AI analysis`); + report.aiAnalysis = await analyzeWithAI(fallbackDiffPath, aiToken, aiModel); + } catch (e) { + console.warn(`⚠️ Fallback AI analysis failed: ${e}`); + } } return report; } diff --git a/src/index.ts b/src/index.ts index e19c735..0037a67 100644 --- a/src/index.ts +++ b/src/index.ts @@ -308,6 +308,7 @@ async function processSingleFile( // Fallback: if AI token is set but no baseline was found, use example diff for AI analysis if (aiToken && !report.aiAnalysis) { + console.log('Fallback:::::::::::::::::::') try { const fallbackDiffPath = path.resolve(__dirname, '..', 'examples', 'rsdoctor-diff.json'); console.log(`ℹ️ No baseline found, falling back to example diff data for AI analysis`); From 2349847b3ef27164fc1e2d78af1ed846d4ee2f19 Mon Sep 17 00:00:00 2001 From: yifan111 Date: Tue, 24 Mar 2026 15:20:11 +0800 Subject: [PATCH 08/11] test: for ai json diff --- dist/index.js | 1 - src/index.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/dist/index.js b/dist/index.js index 6fc6230..d0bd2f5 100644 --- a/dist/index.js +++ b/dist/index.js @@ -96923,7 +96923,6 @@ Respond in concise GitHub-flavored Markdown suitable for a PR comment. If there const currentCommitHash = githubService.getCurrentCommitHash(); console.log(`Current commit hash: ${currentCommitHash}`); const aiToken = process.env.AI_TOKEN || ''; - console.log('aiToken::::::::::', aiToken); const aiModel = (0, core.getInput)('ai_model') || 'claude-3-5-haiku-latest'; if (aiToken) console.log(`🤖 AI analysis enabled (model: ${aiModel})`); let targetCommitHash = null; diff --git a/src/index.ts b/src/index.ts index 0037a67..b57e56a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -349,7 +349,6 @@ async function processSingleFile( console.log(`Current commit hash: ${currentCommitHash}`); const aiToken = process.env.AI_TOKEN || ''; - console.log('aiToken::::::::::', aiToken); const aiModel = getInput('ai_model') || 'claude-3-5-haiku-latest'; if (aiToken) { console.log(`🤖 AI analysis enabled (model: ${aiModel})`); From fe0a136fc1b98c963f8404a98cf3272163fd3f08 Mon Sep 17 00:00:00 2001 From: yifan111 Date: Tue, 24 Mar 2026 15:36:34 +0800 Subject: [PATCH 09/11] test: for ai json diff --- action.yml | 6 +++++- dist/index.js | 23 ++++++++++++++--------- src/ai-analysis.ts | 21 ++++++++++++++------- src/index.ts | 8 +++++--- 4 files changed, 38 insertions(+), 20 deletions(-) diff --git a/action.yml b/action.yml index 7adede6..010748b 100644 --- a/action.yml +++ b/action.yml @@ -21,9 +21,13 @@ inputs: default: '' type: string ai_model: - description: 'AI model to use for degradation analysis (e.g. claude-3-5-haiku-latest, gpt-4o-mini). Provider is auto-detected from the model name prefix.' + description: 'AI model to use for degradation analysis (e.g. claude-3-5-haiku-latest, gpt-4o-mini, qwen-plus). Provider is auto-detected from the model name prefix.' required: false default: 'claude-3-5-haiku-latest' + ai_base_url: + description: 'Custom base URL for AI API (e.g. https://dashscope.aliyuncs.com/compatible-mode/v1 for Qwen). Defaults to official endpoints.' + required: false + default: '' runs: using: 'node20' diff --git a/dist/index.js b/dist/index.js index d0bd2f5..33516b7 100644 --- a/dist/index.js +++ b/dist/index.js @@ -96552,8 +96552,11 @@ var __webpack_exports__ = {}; console.log('✅ Bundle size report card generated successfully'); } function detectProvider(model) { - return model.toLowerCase().startsWith('claude') ? 'anthropic' : 'openai'; + if (model.toLowerCase().startsWith('claude')) return 'anthropic'; + if (model.toLowerCase().startsWith('qwen')) return 'qwen3.5-plus'; + return 'openai'; } + const QWEN_DEFAULT_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; async function callAnthropicAPI(prompt, token, model) { const response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', @@ -96580,8 +96583,8 @@ var __webpack_exports__ = {}; const data = await response.json(); return data.content[0].text; } - async function callOpenAIAPI(prompt, token, model) { - const response = await fetch('https://api.openai.com/v1/chat/completions', { + async function callOpenAIAPI(prompt, token, model, baseUrl = 'https://api.openai.com/v1') { + const response = await fetch(`${baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -96631,7 +96634,7 @@ ${diffStr} Respond in concise GitHub-flavored Markdown suitable for a PR comment. If there are no regressions, say so clearly.`; } - async function analyzeWithAI(diffJsonPath, token, model = 'claude-3-5-haiku-latest') { + async function analyzeWithAI(diffJsonPath, token, model = 'qwen', baseUrl) { if (!token) { console.log('ℹ️ No AI token provided, skipping AI analysis'); return null; @@ -96645,7 +96648,8 @@ Respond in concise GitHub-flavored Markdown suitable for a PR comment. If there const prompt = buildPrompt(diffData); const provider = detectProvider(model); console.log(`🤖 Running AI analysis with ${provider} (${model})...`); - const analysis = 'anthropic' === provider ? await callAnthropicAPI(prompt, token, model) : await callOpenAIAPI(prompt, token, model); + const resolvedBaseUrl = baseUrl || ('qwen3.5-plus' === provider ? QWEN_DEFAULT_BASE_URL : void 0); + const analysis = 'anthropic' === provider ? await callAnthropicAPI(prompt, token, model) : await callOpenAIAPI(prompt, token, model, resolvedBaseUrl); console.log('✅ AI analysis completed'); return { analysis, @@ -96752,7 +96756,7 @@ Respond in concise GitHub-flavored Markdown suitable for a PR comment. If there } return pathParts[0] || 'root'; } - async function processSingleFile(fullPath, currentCommitHash, targetCommitHash, baselineUsedFallback, baselineLatestCommitHash, aiToken, aiModel) { + async function processSingleFile(fullPath, currentCommitHash, targetCommitHash, baselineUsedFallback, baselineLatestCommitHash, aiToken, aiModel, aiBaseUrl) { const fileName = external_path_default().basename(fullPath); const relativePath = external_path_default().relative(process.cwd(), fullPath); const pathParts = relativePath.split(external_path_default().sep); @@ -96886,7 +96890,7 @@ Respond in concise GitHub-flavored Markdown suitable for a PR comment. If there } if (external_fs_.existsSync(defaultDiffJsonPath) && !external_fs_.existsSync(diffJsonPath)) await external_fs_.promises.rename(defaultDiffJsonPath, diffJsonPath); const resolvedJsonPath = external_fs_.existsSync(diffJsonPath) ? diffJsonPath : defaultDiffJsonPath; - report.aiAnalysis = await analyzeWithAI(resolvedJsonPath, aiToken, aiModel); + report.aiAnalysis = await analyzeWithAI(resolvedJsonPath, aiToken, aiModel, aiBaseUrl); } catch (e) { console.warn(`⚠️ Failed to generate JSON diff for AI analysis: ${e}`); } @@ -96898,7 +96902,7 @@ Respond in concise GitHub-flavored Markdown suitable for a PR comment. If there try { const fallbackDiffPath = external_path_default().resolve(__dirname, '..', 'examples', 'rsdoctor-diff.json'); console.log(`ℹ️ No baseline found, falling back to example diff data for AI analysis`); - report.aiAnalysis = await analyzeWithAI(fallbackDiffPath, aiToken, aiModel); + report.aiAnalysis = await analyzeWithAI(fallbackDiffPath, aiToken, aiModel, aiBaseUrl); } catch (e) { console.warn(`⚠️ Fallback AI analysis failed: ${e}`); } @@ -96924,6 +96928,7 @@ Respond in concise GitHub-flavored Markdown suitable for a PR comment. If there console.log(`Current commit hash: ${currentCommitHash}`); const aiToken = process.env.AI_TOKEN || ''; const aiModel = (0, core.getInput)('ai_model') || 'claude-3-5-haiku-latest'; + const aiBaseUrl = (0, core.getInput)('ai_base_url') || void 0; if (aiToken) console.log(`🤖 AI analysis enabled (model: ${aiModel})`); let targetCommitHash = null; let baselineUsedFallback = false; @@ -96981,7 +96986,7 @@ Respond in concise GitHub-flavored Markdown suitable for a PR comment. If there if (isDispatch) console.log('🔧 Processing workflow_dispatch event - uploading artifacts and comparing with baseline'); else console.log('📥 Detected pull request event - processing files'); for (const fullPath of matchedFiles){ - const report = await processSingleFile(fullPath, currentCommitHash, targetCommitHash, baselineUsedFallback, baselineLatestCommitHash, aiToken, aiModel); + const report = await processSingleFile(fullPath, currentCommitHash, targetCommitHash, baselineUsedFallback, baselineLatestCommitHash, aiToken, aiModel, aiBaseUrl); projectReports.push(report); if (isDispatch) { const uploadResponse = await uploadArtifact(fullPath, currentCommitHash); diff --git a/src/ai-analysis.ts b/src/ai-analysis.ts index a34d3f5..e8538df 100644 --- a/src/ai-analysis.ts +++ b/src/ai-analysis.ts @@ -6,10 +6,14 @@ export interface AIAnalysisResult { model: string; } -function detectProvider(model: string): 'anthropic' | 'openai' { - return model.toLowerCase().startsWith('claude') ? 'anthropic' : 'openai'; +function detectProvider(model: string): 'anthropic' | 'openai' | 'qwen3.5-plus' { + if (model.toLowerCase().startsWith('claude')) return 'anthropic'; + if (model.toLowerCase().startsWith('qwen')) return 'qwen3.5-plus'; + return 'openai'; } +const QWEN_DEFAULT_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; + async function callAnthropicAPI(prompt: string, token: string, model: string): Promise { const response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', @@ -34,8 +38,8 @@ async function callAnthropicAPI(prompt: string, token: string, model: string): P return data.content[0].text as string; } -async function callOpenAIAPI(prompt: string, token: string, model: string): Promise { - const response = await fetch('https://api.openai.com/v1/chat/completions', { +async function callOpenAIAPI(prompt: string, token: string, model: string, baseUrl = 'https://api.openai.com/v1'): Promise { + const response = await fetch(`${baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -92,13 +96,15 @@ Respond in concise GitHub-flavored Markdown suitable for a PR comment. If there * Run AI degradation analysis on a bundle-diff JSON file. * * @param diffJsonPath Path to the JSON file produced by `rsdoctor bundle-diff --json` - * @param token AI API key (Anthropic or OpenAI) + * @param token AI API key (Anthropic, OpenAI, or Qwen) * @param model Model name — auto-detects provider from prefix (default: claude-3-5-haiku-latest) + * @param baseUrl Optional base URL override (required for Qwen region selection) */ export async function analyzeWithAI( diffJsonPath: string, token: string, - model = 'claude-3-5-haiku-latest', + model = 'qwen', + baseUrl?: string, ): Promise { if (!token) { console.log('ℹ️ No AI token provided, skipping AI analysis'); @@ -117,10 +123,11 @@ export async function analyzeWithAI( console.log(`🤖 Running AI analysis with ${provider} (${model})...`); + const resolvedBaseUrl = baseUrl || (provider === 'qwen3.5-plus' ? QWEN_DEFAULT_BASE_URL : undefined); const analysis = provider === 'anthropic' ? await callAnthropicAPI(prompt, token, model) - : await callOpenAIAPI(prompt, token, model); + : await callOpenAIAPI(prompt, token, model, resolvedBaseUrl); console.log('✅ AI analysis completed'); return { analysis, provider, model }; diff --git a/src/index.ts b/src/index.ts index b57e56a..e1b4413 100644 --- a/src/index.ts +++ b/src/index.ts @@ -143,6 +143,7 @@ async function processSingleFile( baselineLatestCommitHash?: string, aiToken?: string, aiModel?: string, + aiBaseUrl?: string, ): Promise { const fileName = path.basename(fullPath); const relativePath = path.relative(process.cwd(), fullPath); @@ -296,7 +297,7 @@ async function processSingleFile( } const resolvedJsonPath = fs.existsSync(diffJsonPath) ? diffJsonPath : defaultDiffJsonPath; - report.aiAnalysis = await analyzeWithAI(resolvedJsonPath, aiToken, aiModel); + report.aiAnalysis = await analyzeWithAI(resolvedJsonPath, aiToken, aiModel, aiBaseUrl); } catch (e) { console.warn(`⚠️ Failed to generate JSON diff for AI analysis: ${e}`); } @@ -312,7 +313,7 @@ async function processSingleFile( try { const fallbackDiffPath = path.resolve(__dirname, '..', 'examples', 'rsdoctor-diff.json'); console.log(`ℹ️ No baseline found, falling back to example diff data for AI analysis`); - report.aiAnalysis = await analyzeWithAI(fallbackDiffPath, aiToken, aiModel); + report.aiAnalysis = await analyzeWithAI(fallbackDiffPath, aiToken, aiModel, aiBaseUrl); } catch (e) { console.warn(`⚠️ Fallback AI analysis failed: ${e}`); } @@ -350,6 +351,7 @@ async function processSingleFile( const aiToken = process.env.AI_TOKEN || ''; const aiModel = getInput('ai_model') || 'claude-3-5-haiku-latest'; + const aiBaseUrl = getInput('ai_base_url') || undefined; if (aiToken) { console.log(`🤖 AI analysis enabled (model: ${aiModel})`); } @@ -445,7 +447,7 @@ async function processSingleFile( } for (const fullPath of matchedFiles) { - const report = await processSingleFile(fullPath, currentCommitHash, targetCommitHash, baselineUsedFallback, baselineLatestCommitHash, aiToken, aiModel); + const report = await processSingleFile(fullPath, currentCommitHash, targetCommitHash, baselineUsedFallback, baselineLatestCommitHash, aiToken, aiModel, aiBaseUrl); projectReports.push(report); // For workflow_dispatch, also upload artifacts From adeeb4c8af2864e806dcbb2cf4159d32374ff01e Mon Sep 17 00:00:00 2001 From: yifan111 Date: Tue, 24 Mar 2026 16:07:07 +0800 Subject: [PATCH 10/11] test: for ai json diff --- action.yml | 2 +- dist/index.js | 2 +- src/index.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/action.yml b/action.yml index 010748b..0c0713e 100644 --- a/action.yml +++ b/action.yml @@ -23,7 +23,7 @@ inputs: ai_model: description: 'AI model to use for degradation analysis (e.g. claude-3-5-haiku-latest, gpt-4o-mini, qwen-plus). Provider is auto-detected from the model name prefix.' required: false - default: 'claude-3-5-haiku-latest' + default: 'qwen3.5-plus' ai_base_url: description: 'Custom base URL for AI API (e.g. https://dashscope.aliyuncs.com/compatible-mode/v1 for Qwen). Defaults to official endpoints.' required: false diff --git a/dist/index.js b/dist/index.js index 33516b7..2386f6c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -96927,7 +96927,7 @@ Respond in concise GitHub-flavored Markdown suitable for a PR comment. If there const currentCommitHash = githubService.getCurrentCommitHash(); console.log(`Current commit hash: ${currentCommitHash}`); const aiToken = process.env.AI_TOKEN || ''; - const aiModel = (0, core.getInput)('ai_model') || 'claude-3-5-haiku-latest'; + const aiModel = (0, core.getInput)('ai_model') || 'qwen3.5-plus'; const aiBaseUrl = (0, core.getInput)('ai_base_url') || void 0; if (aiToken) console.log(`🤖 AI analysis enabled (model: ${aiModel})`); let targetCommitHash = null; diff --git a/src/index.ts b/src/index.ts index e1b4413..d1fd655 100644 --- a/src/index.ts +++ b/src/index.ts @@ -350,7 +350,7 @@ async function processSingleFile( console.log(`Current commit hash: ${currentCommitHash}`); const aiToken = process.env.AI_TOKEN || ''; - const aiModel = getInput('ai_model') || 'claude-3-5-haiku-latest'; + const aiModel = getInput('ai_model') || 'qwen3.5-plus'; const aiBaseUrl = getInput('ai_base_url') || undefined; if (aiToken) { console.log(`🤖 AI analysis enabled (model: ${aiModel})`); From eba12267b4f284f07bd4dac5479322a724a1c69c Mon Sep 17 00:00:00 2001 From: yifan111 Date: Tue, 24 Mar 2026 16:25:34 +0800 Subject: [PATCH 11/11] ai: try for cli-anything --- dist/index.js | 2 +- src/index.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/dist/index.js b/dist/index.js index 2386f6c..d0ab8f2 100644 --- a/dist/index.js +++ b/dist/index.js @@ -97093,7 +97093,7 @@ Respond in concise GitHub-flavored Markdown suitable for a PR comment. If there commentBody += `\n📦 **Download Diff Report**: [${report.projectName} Bundle Diff](${artifactDownloadLink})\n\n`; } } - if (reportsWithChanges.length > 1) commentBody += '\n\n'; + commentBody += '\n\n'; } const reportsWithAI = projectReports.filter((r)=>r.aiAnalysis); if (reportsWithAI.length > 0) { diff --git a/src/index.ts b/src/index.ts index d1fd655..ea5c3aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -605,9 +605,7 @@ async function processSingleFile( } } - if (reportsWithChanges.length > 1) { - commentBody += '\n\n'; - } + commentBody += '\n\n'; } // Append AI degradation analysis if available (one section per project that has it)