Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
node-version: 24
cache: 'pnpm'

# Update npm to the latest version to enable OIDC
Expand Down
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other inputs in this file specify type: string, but ai_model does not. For consistency (and clearer metadata for tooling), add type: string to the new ai_model input.

Suggested change
default: 'claude-3-5-haiku-latest'
default: 'claude-3-5-haiku-latest'
type: string

Copilot uses AI. Check for mistakes.

runs:
using: 'node20'
Expand Down
424 changes: 424 additions & 0 deletions dist/682.js

Large diffs are not rendered by default.

380 changes: 380 additions & 0 deletions dist/749.js

Large diffs are not rendered by default.

32,856 changes: 32,505 additions & 351 deletions dist/index.js

Large diffs are not rendered by default.

14 changes: 10 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,17 @@
"access": "public"
},
"devDependencies": {
"@ai-sdk/anthropic": "^3.0.69",
"@ai-sdk/deepseek": "^2.0.29",
"@ai-sdk/google": "^3.0.63",
"@ai-sdk/openai": "^3.0.52",
"ai": "^6.0.159",
"@actions/artifact": "^2.3.2",
"@actions/core": "^1.2.6",
"@actions/github": "^4.0.0",
"@playwright/test": "^1.42.1",
"@rsdoctor/cli": "1.3.3-beta.2",
"@rsdoctor/client": "1.3.3-beta.2",
"@rsdoctor/cli": "1.5.8",
"@rsdoctor/client": "1.5.8",
"@rslib/core": "^0.16.0",
"@rstest/core": "^0.5.4",
"@types/node": "^24.5.2",
Expand All @@ -53,6 +58,7 @@
"yauzl": "^3.2.0"
},
"overrides": {
"@rsdoctor/client": "1.3.3-beta.2"
}
"@rsdoctor/client": "1.5.8"
},
"packageManager": "pnpm@9.15.9+sha512.68046141893c66fad01c079231128e9afb89ef87e2691d69e4d40eee228988295fd4682181bae55b58418c3a253bde65a505ec7c5f9403ece5cc3cd37dcf2531"
}
437 changes: 233 additions & 204 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

File renamed without changes.
96 changes: 96 additions & 0 deletions src/ai-analysis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import * as fs from 'fs';
import { generateText, type LanguageModel } from 'ai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createDeepSeek } from '@ai-sdk/deepseek';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { createOpenAI } from '@ai-sdk/openai';
import { buildPrompt } from './prompt';

export interface AIAnalysisResult {
analysis: string;
provider: string;
model: string;
}

type Provider = 'anthropic' | 'openai' | 'google' | 'deepseek' | 'qwen';

function detectProvider(model: string): Provider {
const m = model.toLowerCase();
if (m.startsWith('claude')) return 'anthropic';
if (m.startsWith('gemini')) return 'google';
if (m.startsWith('deepseek')) return 'deepseek';
if (m.startsWith('qwen')) return 'qwen';
return 'openai';
}

function createModel(provider: Provider, model: string, token: string): LanguageModel {
switch (provider) {
case 'anthropic': {
const anthropic = createAnthropic({ apiKey: token });
return anthropic(model);
}
case 'google': {
const google = createGoogleGenerativeAI({ apiKey: token });
return google(model);
}
case 'deepseek': {
const deepseek = createDeepSeek({ apiKey: token });
return deepseek(model);
}
case 'qwen': {
const qwen = createOpenAI({
apiKey: token,
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
});
return qwen(model);
}
default: {
const openai = createOpenAI({ apiKey: token });
return openai(model);
}
}
}

/**
* 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<AIAnalysisResult | null> {
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'));
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

analyzeWithAI is async but uses readFileSync, which blocks the Node.js event loop. Switching to await fs.promises.readFile(...) keeps the action responsive and avoids blocking I/O (especially if multiple projects are processed).

Suggested change
const diffData: unknown = JSON.parse(fs.readFileSync(diffJsonPath, 'utf8'));
const diffData: unknown = JSON.parse(await fs.promises.readFile(diffJsonPath, 'utf8'));

Copilot uses AI. Check for mistakes.
const prompt = buildPrompt(diffData);
const provider = detectProvider(model);

console.log(`🤖 Running AI analysis with ${provider} (${model})...`);

const llm = createModel(provider, model, token);
const { text: analysis } = await generateText({
model: llm,
maxOutputTokens: 2048,
prompt,
});

console.log('✅ AI analysis completed');
return { analysis, provider, model };
} catch (error) {
console.warn(`⚠️ AI analysis failed: ${error}`);
return null;
}
}
69 changes: 66 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -99,6 +100,7 @@ interface ProjectReport {
diffHtmlArtifactId?: number;
baselineUsedFallback?: boolean;
baselineLatestCommitHash?: string;
aiAnalysis?: AIAnalysisResult | null;
}

export function extractProjectName(filePath: string): string {
Expand Down Expand Up @@ -152,6 +154,8 @@ async function processSingleFile(
targetCommitHash: string | null,
baselineUsedFallback?: boolean,
baselineLatestCommitHash?: string,
aiToken?: string,
aiModel?: string,
): Promise<ProjectReport> {
const fileName = path.basename(fullPath);
const relativePath = path.relative(process.cwd(), fullPath);
Expand Down Expand Up @@ -273,11 +277,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 });
Comment on lines +299 to +301
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using sh -c with an interpolated command string built from file paths can enable shell injection if baselineJsonPath or fullPath contains shell metacharacters (these paths can be PR-controlled in a GitHub Action). Prefer calling execFileAsync without a shell by passing npx (or node) and arguments as an array, which avoids quoting/escaping issues and is safer.

Suggested change
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 });
const npxArgs = [
'@rsdoctor/cli',
'bundle-diff',
'--json',
'--baseline',
baselineJsonPath,
'--current',
fullPath,
];
console.log(`🛠️ Running rsdoctor --json via npx with args: ${JSON.stringify(npxArgs)}`);
await execFileAsync('npx', npxArgs, { cwd: tempOutDir });

Copilot uses AI. Check for mistakes.
} 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;
}

Expand Down Expand Up @@ -307,7 +349,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';
Comment on lines +353 to +354
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The action introduces ai_model as an input, but the token is only configurable via process.env.AI_TOKEN (not an action input) and isn’t documented in action.yml in this diff. Consider adding an ai_token input (and calling core.setSecret on it) or explicitly documenting the required env var in action.yml so users can discover/configure it reliably.

Copilot uses AI. Check for mistakes.
if (aiToken) {
console.log(`🤖 AI analysis enabled (model: ${aiModel})`);
}

let targetCommitHash: string | null = null;
let baselineUsedFallback = false;
let baselineLatestCommitHash: string | undefined = undefined;
Expand Down Expand Up @@ -399,7 +447,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
Expand Down Expand Up @@ -562,6 +610,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 += '<details>\n<summary><b>🤖 AI Degradation Analysis</b> (Click to expand)</summary>\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 += `<sub>Analysis by ${report.aiAnalysis.model}</sub>\n\n`;
Comment on lines +622 to +623
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI output is injected directly into the PR comment body as GitHub-flavored Markdown. This can unintentionally trigger @mentions, issue/PR links, or other noisy formatting. Consider neutralizing mentions (e.g., replacing @ with @\\u200b), or constraining the rendering (e.g., wrapping the AI response in a blockquote or details section that discourages mention expansion) before posting.

Copilot uses AI. Check for mistakes.
}
commentBody += '</details>\n\n';
}

commentBody += '*Generated by [Rsdoctor GitHub Action](https://rsdoctor.rs/guide/start/action)*';

try {
Expand Down
45 changes: 45 additions & 0 deletions src/prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
const MAX_CHARS = 50000;

export function buildPrompt(diffData: unknown): string {
// Truncate large diff data to avoid token limits (~50k chars)
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 senior frontend performance engineer. Analyze the Rsdoctor bundle-diff JSON below (baseline → current) and produce a concise GitHub PR comment in Markdown.

## Output format

### 📊 Size Changes

| Asset / Chunk | Baseline | Current | Δ Size | Δ % | Initial? |
|---|---|---|---|---|---|

(Only list entries with **>5 % or >10 KB** increase. If none, write "No significant regressions detected 🎉".)

### 🔍 Root Cause Analysis
- Bullet points: which modules / dependencies drove each regression.

### ⚠️ Risk Assessment
Overall severity: **Low / Medium / High**
- One-sentence justification focusing on initial-chunk impact and total size delta.

### 💡 Optimization Suggestions
- Numbered, actionable steps (e.g. code-split, tree-shake, replace heavy deps).

## Priority rules
1. Initial / entry chunks > async chunks > static assets.
2. Newly added large modules or duplicate dependencies deserve explicit callout.
3. If total bundle size *decreased*, highlight the wins instead.

## Constraints
- Be concise — aim for <400 words.
- Use exact numbers from the data; do not fabricate figures.
- If the diff data is empty or shows no meaningful change, state that clearly and skip the table.

Bundle diff data:
\`\`\`json
${diffStr}
\`\`\``;
}
Loading