next.js/.github/actions/next-stats-action/src/add-comment.js
add-comment.js1273 lines38.7 KB
const path = require('path')
const fs = require('fs').promises
const fetch = require('node-fetch')
const prettyMs = require('pretty-ms')
const logger = require('./util/logger')
const prettyBytes = require('pretty-bytes')
const { benchTitle } = require('./constants')

// Try to load Vercel KV - may not be available in all environments
let kv = null
async function getKV() {
  if (kv) return kv
  if (!process.env.KV_REST_API_URL || !process.env.KV_REST_API_TOKEN) {
    return null
  }
  try {
    const { createClient } = require('@vercel/kv')
    kv = createClient({
      url: process.env.KV_REST_API_URL,
      token: process.env.KV_REST_API_TOKEN,
    })
    return kv
  } catch (e) {
    logger.error('Failed to initialize Vercel KV:', e)
    return null
  }
}

const KV_STATS_KEY = 'next-stats-history'
const MAX_HISTORY_ENTRIES = 100

// ============================================================================
// Metric Configuration
// ============================================================================

// Human-readable labels for metrics
const METRIC_LABELS = {
  // Dev boot metrics - Turbopack (Listen = port listening, First Request = HTTP responding)
  nextDevColdListenDurationTurbo: 'Cold (Listen)',
  nextDevColdReadyDurationTurbo: 'Cold (First Request)',
  nextDevWarmListenDurationTurbo: 'Warm (Listen)',
  nextDevWarmReadyDurationTurbo: 'Warm (First Request)',
  // Dev boot metrics - Webpack
  nextDevColdListenDurationWebpack: 'Webpack Cold (Listen)',
  nextDevColdReadyDurationWebpack: 'Webpack Cold (First Request)',
  nextDevWarmListenDurationWebpack: 'Webpack Warm (Listen)',
  nextDevWarmReadyDurationWebpack: 'Webpack Warm (First Request)',
  // Production metrics
  nextStartReadyDuration: 'Prod Start',
  // Build metrics - Webpack
  buildDurationWebpack: 'Webpack Build Time',
  buildDurationCachedWebpack: 'Webpack Build Time (cached)',
  // Build metrics - Turbopack
  buildDurationTurbo: 'Turbo Build Time',
  buildDurationCachedTurbo: 'Turbo Build Time (cached)',
  // General metrics
  nodeModulesSize: 'node_modules Size',
  swcBinarySize: 'SWC Binary Size',
}

// Group configuration for organizing the comment
const METRIC_GROUPS = {
  'Dev Server': {
    icon: '⚡',
    description:
      'Boot time for `next dev` (Turbopack). Cold = fresh build, Warm = with cache.',
    metrics: [
      {
        label: 'Cold (Listen)',
        key: 'nextDevColdListenDurationTurbo',
        description: 'Time until TCP port accepts connections',
      },
      {
        label: 'Cold (Ready in log)',
        key: 'nextDevColdReadyInDurationTurbo',
        description: 'Time until "Ready in X" log message is printed',
      },
      {
        label: 'Cold (First Request)',
        key: 'nextDevColdReadyDurationTurbo',
        description: 'Time until first HTTP request succeeds',
      },
      {
        label: 'Warm (Listen)',
        key: 'nextDevWarmListenDurationTurbo',
        description: 'Time until TCP port accepts connections (cached)',
      },
      {
        label: 'Warm (Ready in log)',
        key: 'nextDevWarmReadyInDurationTurbo',
        description: 'Time until "Ready in X" log message is printed (cached)',
      },
      {
        label: 'Warm (First Request)',
        key: 'nextDevWarmReadyDurationTurbo',
        description: 'Time until first HTTP request succeeds (cached)',
      },
    ],
    webpackGroup: 'Dev Server (Webpack)',
  },
  'Dev Server (Webpack)': {
    icon: '📦',
    isLegacy: true,
    description:
      'Boot time for `next dev` (Webpack). Cold = fresh build, Warm = with cache.',
    metrics: [
      {
        label: 'Cold (Listen)',
        key: 'nextDevColdListenDurationWebpack',
        description: 'Time until TCP port accepts connections',
      },
      {
        label: 'Cold (Ready in log)',
        key: 'nextDevColdReadyInDurationWebpack',
        description: 'Time until "Ready in X" log message is printed',
      },
      {
        label: 'Cold (First Request)',
        key: 'nextDevColdReadyDurationWebpack',
        description: 'Time until first HTTP request succeeds',
      },
      {
        label: 'Warm (Listen)',
        key: 'nextDevWarmListenDurationWebpack',
        description: 'Time until TCP port accepts connections (cached)',
      },
      {
        label: 'Warm (Ready in log)',
        key: 'nextDevWarmReadyInDurationWebpack',
        description: 'Time until "Ready in X" log message is printed (cached)',
      },
      {
        label: 'Warm (First Request)',
        key: 'nextDevWarmReadyDurationWebpack',
        description: 'Time until first HTTP request succeeds (cached)',
      },
    ],
  },
  'Production Builds': {
    icon: '⚡',
    description: 'Time for `next build` (Turbopack).',
    metrics: [
      {
        label: 'Fresh Build',
        key: 'buildDurationTurbo',
      },
      {
        label: 'Cached Build',
        key: 'buildDurationCachedTurbo',
      },
    ],
    webpackGroup: 'Production Builds (Webpack)',
  },
  'Production Builds (Webpack)': {
    icon: '📦',
    isLegacy: true,
    description: 'Time for `next build --webpack`.',
    metrics: [
      {
        label: 'Fresh Build',
        key: 'buildDurationWebpack',
      },
      {
        label: 'Cached Build',
        key: 'buildDurationCachedWebpack',
      },
      {
        label: 'node_modules Size',
        key: 'nodeModulesSize',
        type: 'bytes',
      },
    ],
  },
  'Production Runtime': {
    icon: '🚀',
    description: 'Boot time for `next start` (bundler-agnostic).',
    metrics: [
      {
        label: 'Start (First Request)',
        key: 'nextStartReadyDuration',
      },
    ],
  },
}

// Per-metric significance thresholds
// A change is insignificant if:
//   - (absoluteDiff < absoluteMin AND percentDiff < percentMin), OR
//   - percentDiff < percentOnly
//
// Time metrics have HIGH VARIANCE (CPU, I/O, cache) - need generous thresholds
// Size metrics are DETERMINISTIC - can be tighter
const METRIC_THRESHOLDS = {
  // Dev boot times (~300-400ms): high variance from CI
  // <100ms AND <15%, OR <3%
  ms: { absoluteMin: 100, percentMin: 15, percentOnly: 3 },

  // Build times (~13s): high variance, longer duration
  // <500ms AND <5%, OR <2%
  buildDurationTurbo: { absoluteMin: 500, percentMin: 5, percentOnly: 2 },
  buildDurationCachedTurbo: { absoluteMin: 500, percentMin: 5, percentOnly: 2 },
  buildDurationWebpack: { absoluteMin: 500, percentMin: 5, percentOnly: 2 },
  buildDurationCachedWebpack: {
    absoluteMin: 500,
    percentMin: 5,
    percentOnly: 2,
  },

  // node_modules (~450MB): deterministic, huge baseline
  // <10KB AND <1%, OR <0.01%
  nodeModulesSize: { absoluteMin: 10240, percentMin: 1, percentOnly: 0.01 },

  // SWC native binary (~tens of MB): deterministic, but smaller baseline
  // <10KB AND <0.5%, OR <0.05%
  swcBinarySize: { absoluteMin: 10240, percentMin: 0.5, percentOnly: 0.05 },

  // Bundle sizes (KB-MB): deterministic
  // <2KB AND <1%, OR <0.1%
  bytes: { absoluteMin: 2048, percentMin: 1, percentOnly: 0.1 },
}

// ============================================================================
// Historical Data (Vercel KV)
// ============================================================================

async function loadHistory() {
  const kvClient = await getKV()
  if (!kvClient) {
    logger('KV not configured - historical trends unavailable')
    return { entries: [] }
  }

  try {
    const data = await kvClient.lrange(KV_STATS_KEY, -MAX_HISTORY_ENTRIES, -1)
    const entries = data.map((d) => (typeof d === 'string' ? JSON.parse(d) : d))
    logger(`Loaded ${entries.length} historical entries from KV`)
    return { entries }
  } catch (e) {
    logger.error('Failed to load history from KV:', e)
    return { entries: [] }
  }
}

async function saveToHistory(entry) {
  const kvClient = await getKV()
  if (!kvClient) return

  try {
    await kvClient.rpush(KV_STATS_KEY, JSON.stringify(entry))
    // Trim to keep only last N entries
    await kvClient.ltrim(KV_STATS_KEY, -MAX_HISTORY_ENTRIES, -1)
    logger('Saved stats to KV history')
  } catch (e) {
    logger.error('Failed to save to KV:', e)
  }
}

// ============================================================================
// Formatting Utilities
// ============================================================================

function prettifyTime(ms) {
  if (typeof ms !== 'number') return 'N/A'
  if (ms < 1000) {
    return `${Math.round(ms)}ms`
  }
  return `${(ms / 1000).toFixed(3)}s`
}

const prettify = (val, type = 'bytes') => {
  if (typeof val !== 'number') return 'N/A'
  return type === 'bytes' ? prettyBytes(val) : prettifyTime(val)
}

const round = (num, places) => {
  const placesFactor = Math.pow(10, places)
  return Math.round(num * placesFactor) / placesFactor
}

const shortenLabel = (itemKey) =>
  itemKey.length > 24
    ? `${itemKey.slice(0, 12)}..${itemKey.slice(-12)}`
    : itemKey

function getMetricLabel(key) {
  return METRIC_LABELS[key] || shortenLabel(key)
}

function formatChange(mainVal, diffVal, type = 'bytes', metricKey = null) {
  if (typeof mainVal !== 'number' || typeof diffVal !== 'number') {
    return { text: '-', significant: false, improved: false, regression: false }
  }

  const diff = diffVal - mainVal
  const percentChange = mainVal > 0 ? (diff / mainVal) * 100 : 0

  // Get threshold config: prefer metric-specific, then type-based, then default to bytes
  const threshold =
    METRIC_THRESHOLDS[metricKey] ||
    METRIC_THRESHOLDS[type] ||
    METRIC_THRESHOLDS.bytes

  // A change is insignificant if:
  //   - (absoluteDiff < absoluteMin AND percentDiff < percentMin), OR
  //   - percentDiff < percentOnly (definitely noise regardless of absolute)
  // Exception: if mainVal is 0 and diff is non-zero, always significant
  const isInsignificant =
    mainVal === 0 && diff !== 0
      ? false
      : (Math.abs(diff) < threshold.absoluteMin &&
          Math.abs(percentChange) < threshold.percentMin) ||
        Math.abs(percentChange) < threshold.percentOnly

  if (isInsignificant) {
    return { text: '✓', significant: false, improved: false, regression: false }
  }

  const improved = diff < 0
  const regression = diff > 0
  // Clear icons: 🔴 regression, 🟢 improvement
  const icon = improved ? '🟢' : '🔴'
  const sign = diff > 0 ? '+' : ''
  const formatted = prettify(Math.abs(diff), type)
  const pct = `(${percentChange > 0 ? '+' : ''}${Math.round(percentChange)}%)`

  return {
    text: `${icon} ${sign}${formatted} ${pct}`.trim(),
    significant: true,
    improved,
    regression,
  }
}

function generateTrendBar(values) {
  if (!values || values.length < 2) return ''

  const min = Math.min(...values)
  const max = Math.max(...values)
  const range = max - min

  if (range === 0) return '▁▁▁▁▁' // All values the same

  // Unicode bar characters from short to tall
  const bars = '▁▂▃▄▅▆▇█'

  // Take last 5 values for a compact trend
  const recent = values.slice(-5)

  return recent
    .map((v) => {
      const normalized = (v - min) / range
      const index = Math.min(
        Math.floor(normalized * bars.length),
        bars.length - 1
      )
      return bars[index]
    })
    .join('')
}

function getHistoricalValues(history, metricKey, limit = 15) {
  if (!history?.entries?.length) return []
  return history.entries
    .slice(-limit)
    .map((e) => e.metrics?.[metricKey])
    .filter((v) => typeof v === 'number')
}

// ============================================================================
// Bundle Size Aggregation
// ============================================================================

/**
 * Compute aggregate totals for each bundle group for KV persistence.
 * This allows tracking bundle size trends over time even when individual
 * files can't be matched (Turbopack uses content-hash filenames).
 */
function computeBundleGroupTotals(stats) {
  const totals = {}

  for (const [groupKey, groupStats] of Object.entries(stats)) {
    if (groupKey === 'General' || groupKey === benchTitle) continue
    if (!groupStats || typeof groupStats !== 'object') continue

    // Sum gzip values for each group
    let total = 0
    for (const [key, value] of Object.entries(groupStats)) {
      if (key.endsWith(' gzip') && typeof value === 'number') {
        total += value
      }
    }

    // Create stable key from group name
    // "Client Bundles (main) (Turbopack)" → "clientBundlesMainTurbopackTotal"
    const stableKey =
      groupKey
        .replace(/[^a-zA-Z0-9]/g, ' ')
        .trim()
        .split(/\s+/)
        .filter((w) => w.length > 0)
        .map((w, i) =>
          i === 0
            ? w.toLowerCase()
            : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()
        )
        .join('') + 'Total'

    totals[stableKey] = total
  }

  return totals
}

// ============================================================================
// Comment Generation
// ============================================================================

// Determine if a metric is time-based (ms) or size-based (bytes)
function getMetricType(metricKey) {
  if (metricKey.includes('Duration')) {
    return 'ms'
  }
  if (metricKey.includes('Size')) {
    return 'bytes'
  }
  return 'ms' // default to ms for performance metrics
}

function generateChangeSummary(mainStats, diffStats, history) {
  // Collect all significant changes across all metrics
  const changes = []

  // Check General metrics
  const mainGeneral = mainStats?.General || {}
  const diffGeneral = diffStats?.General || {}

  for (const key of Object.keys({ ...mainGeneral, ...diffGeneral })) {
    const mainVal = mainGeneral[key]
    const diffVal = diffGeneral[key]
    const type = getMetricType(key)
    const change = formatChange(mainVal, diffVal, type, key)

    if (change.significant) {
      const histValues = getHistoricalValues(history, key)
      changes.push({
        metric: getMetricLabel(key),
        mainVal: prettify(mainVal, type),
        diffVal: prettify(diffVal, type),
        change: change.text,
        trend: generateTrendBar(histValues),
        improved: change.improved,
        regression: change.regression,
      })
    }
  }

  if (changes.length === 0) {
    return `### ✅ No significant changes detected\n\n`
  }

  // Sort: regressions first, then improvements
  changes.sort((a, b) => {
    if (a.regression !== b.regression) return a.regression ? -1 : 1
    return 0
  })

  const regressions = changes.filter((c) => c.regression)
  const improvements = changes.filter((c) => c.improved)

  // Clear headline showing regressions
  let headline = ''
  if (regressions.length > 0) {
    headline = `### 🔴 ${regressions.length} regression${regressions.length > 1 ? 's' : ''}`
    if (improvements.length > 0) {
      headline += `, ${improvements.length} improvement${improvements.length > 1 ? 's' : ''}`
    }
  } else {
    headline = `### 🟢 ${improvements.length} improvement${improvements.length > 1 ? 's' : ''}`
  }

  const hasTrends = changes.some((c) => c.trend)
  let summary = `${headline}\n\n`

  if (hasTrends) {
    summary += `| Metric | Canary | PR | Change | Trend |\n`
    summary += `|:-------|-------:|---:|-------:|:-----:|\n`
    for (const c of changes) {
      summary += `| ${c.metric} | ${c.mainVal} | ${c.diffVal} | ${c.change} | ${c.trend} |\n`
    }
  } else {
    summary += `| Metric | Canary | PR | Change |\n`
    summary += `|:-------|-------:|---:|-------:|\n`
    for (const c of changes) {
      summary += `| ${c.metric} | ${c.mainVal} | ${c.diffVal} | ${c.change} |\n`
    }
  }

  return summary + '\n'
}

function generateGlossary() {
  return `<details>
<summary><strong>📖 Metrics Glossary</strong></summary>

**Dev Server Metrics:**
- **Listen** = TCP port starts accepting connections
- **First Request** = HTTP server returns successful response
- **Cold** = Fresh build (no cache)
- **Warm** = With cached build artifacts

**Build Metrics:**
- **Fresh** = Clean build (no .next directory)
- **Cached** = With existing .next directory

**Change Thresholds:**
- Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
- Size: Changes < 1KB AND < 1% are insignificant
- All other changes are flagged to catch regressions

</details>

`
}

function generateMetricsTable(
  groupName,
  config,
  mainGroup,
  diffGroup,
  history,
  isCollapsed = false
) {
  // Pre-compute all row data to check if we have any trends
  const rowData = []
  let hasTrends = false

  for (const metricDef of config.metrics) {
    const mainVal = mainGroup[metricDef.key]
    const diffVal = diffGroup[metricDef.key]

    if (mainVal === undefined && diffVal === undefined) continue

    const metricType = metricDef.type || 'ms'
    const mainStr = prettify(mainVal, metricType)
    const diffStr = prettify(diffVal, metricType)
    const change = formatChange(mainVal, diffVal, metricType, metricDef.key)
    const histValues = getHistoricalValues(history, metricDef.key)
    const sparkline = generateTrendBar(histValues)

    if (sparkline) hasTrends = true

    rowData.push({
      label: metricDef.label,
      mainStr,
      diffStr,
      changeText: change.text,
      sparkline,
    })
  }

  if (rowData.length === 0) return ''

  // Build rows with or without trend column
  let rows = ''
  for (const row of rowData) {
    if (hasTrends) {
      rows += `| ${row.label} | ${row.mainStr} | ${row.diffStr} | ${row.changeText} | ${row.sparkline} |\n`
    } else {
      rows += `| ${row.label} | ${row.mainStr} | ${row.diffStr} | ${row.changeText} |\n`
    }
  }

  const header = hasTrends
    ? `| Metric | Canary | PR | Change | Trend |
|:-------|-------:|---:|-------:|:-----:|`
    : `| Metric | Canary | PR | Change |
|:-------|-------:|---:|-------:|`

  const table = `### ${config.icon} ${groupName}

${header}
${rows}
`

  // Wrap legacy/webpack tables in collapsible details
  if (isCollapsed) {
    return `<details>
<summary><strong>${config.icon} ${groupName} (Legacy)</strong></summary>

${table}
</details>

`
  }

  return table
}

function generatePerformanceSection(mainStats, diffStats, history) {
  let content = ''

  content += generateGlossary()

  const mainGroup = mainStats?.General || {}
  const diffGroup = diffStats?.General || {}

  // Render groups in order: show Turbopack tables, then collapse Webpack tables
  for (const [groupName, config] of Object.entries(METRIC_GROUPS)) {
    // Skip legacy groups - they'll be rendered after their corresponding Turbopack group
    if (config.isLegacy) continue

    // Render Turbopack/main group prominently
    content += generateMetricsTable(
      groupName,
      config,
      mainGroup,
      diffGroup,
      history,
      false
    )

    // If this group has a corresponding Webpack group, render it collapsed
    if (config.webpackGroup) {
      const webpackConfig = METRIC_GROUPS[config.webpackGroup]
      if (webpackConfig) {
        content += generateMetricsTable(
          config.webpackGroup,
          webpackConfig,
          mainGroup,
          diffGroup,
          history,
          true // collapsed
        )
      }
    }
  }

  return content
}

// Base group names (without bundler suffix)
const BASE_BUNDLE_GROUPS = {
  client: [
    'Client Bundles (main)',
    'Client Pages',
    'Legacy Client Bundles (polyfills)',
  ],
  server: ['Edge SSR bundle Size', 'Middleware size'],
  // Next Runtimes are built independently of Turbopack/Webpack
  shared: ['Next Runtimes'],
  other: ['Client Build Manifests', 'Rendered Page Sizes', 'build cache'],
}

// Bundler configuration
const BUNDLERS_CONFIG = [
  { name: 'Webpack', icon: '📦' },
  { name: 'Turbopack', icon: '⚡' },
]

// Helper to check if a group name belongs to a bundler category
function getBundlerFromGroupKey(groupKey) {
  for (const bundler of BUNDLERS_CONFIG) {
    if (groupKey.endsWith(`(${bundler.name})`)) {
      return bundler
    }
  }
  return null
}

// Helper to get the base group name (without bundler suffix)
function getBaseGroupName(groupKey) {
  return groupKey.replace(/ \((Webpack|Turbopack)\)$/, '')
}

function generateBundleGroup(groupKey, result, tableHead) {
  const gzipIgnoreRegex = new RegExp(`(General|^Serverless|${benchTitle})`)
  const mainRepoGroup = result.mainRepoStats[groupKey] || {}
  const diffRepoGroup = result.diffRepoStats[groupKey] || {}
  const itemKeys = new Set([
    ...Object.keys(mainRepoGroup),
    ...Object.keys(diffRepoGroup),
  ])

  // Detect pure-hash filenames (Turbopack uses content-hash only, can't be matched)
  // Pattern: 16 hex chars followed by .js or .css (with optional .map or gzip suffix)
  const pureHashPattern = /^[0-9a-f]{16}\.(js|css)/
  let pureHashCount = 0
  let totalGzipItems = 0

  itemKeys.forEach((itemKey) => {
    if (itemKey.endsWith('gzip')) {
      totalGzipItems++
      // Extract base filename (remove ' gzip' suffix)
      const baseName = itemKey.replace(/ gzip$/, '')
      if (pureHashPattern.test(baseName)) {
        pureHashCount++
      }
    }
  })

  // If more than 50% of items are pure-hash files, show totals only
  const isPureHashGroup =
    totalGzipItems > 0 && pureHashCount / totalGzipItems > 0.5

  let groupTable = tableHead
  let mainRepoTotal = 0
  let diffRepoTotal = 0
  let hasItems = false
  let matchedItems = 0
  let unmatchedItems = 0

  itemKeys.forEach((itemKey) => {
    const isGzipItem = itemKey.endsWith('gzip')
    const mainItemVal = mainRepoGroup[itemKey]
    const diffItemVal = diffRepoGroup[itemKey]

    // Skip non-gzip for most groups, skip gzip for serverless
    if (groupKey.startsWith('Serverless') && isGzipItem) return
    if (!isGzipItem && !groupKey.match(gzipIgnoreRegex)) return

    hasItems = true

    // Track matched vs unmatched items
    if (typeof mainItemVal === 'number' && typeof diffItemVal === 'number') {
      matchedItems++
    } else {
      unmatchedItems++
    }

    if (typeof mainItemVal === 'number') mainRepoTotal += mainItemVal
    if (typeof diffItemVal === 'number') diffRepoTotal += diffItemVal

    // Only add individual rows if not a pure-hash group
    if (!isPureHashGroup) {
      const mainItemStr = prettify(mainItemVal, 'bytes')
      const diffItemStr = prettify(diffItemVal, 'bytes')
      const change = formatChange(mainItemVal, diffItemVal, 'bytes')
      groupTable += `| ${shortenLabel(itemKey)} | ${mainItemStr} | ${diffItemStr} | ${change.text} |\n`
    }
  })

  if (!hasItems) return null

  const totalChange = diffRepoTotal - mainRepoTotal
  let totalChangeStr = '✓'

  if (totalChange !== 0) {
    const icon = totalChange > 0 ? '⚠️' : '✅'
    const sign = totalChange > 0 ? '+' : '-'
    totalChangeStr = `${icon} ${sign}${prettyBytes(Math.abs(totalChange))}`
  }

  // Friendly names for groups
  const friendlyNames = {
    'Client Bundles (main)': 'Main Bundles',
    'Legacy Client Bundles (polyfills)': 'Polyfills',
    'Client Pages': 'Pages',
    'Client Build Manifests': 'Build Manifests',
    'Rendered Page Sizes': 'HTML Output',
    'Edge SSR bundle Size': 'Edge SSR',
    'Middleware size': 'Middleware',
    'Next Runtimes': 'Runtimes',
    'build cache': 'Build Cache',
  }

  const baseGroupName = getBaseGroupName(groupKey)
  const displayName = friendlyNames[baseGroupName] || groupKey

  // For pure-hash groups, show a simplified view with just totals
  if (isPureHashGroup) {
    return `<details>
<summary>${displayName}: **${prettyBytes(mainRepoTotal)}** → **${prettyBytes(diffRepoTotal)}** ${totalChangeStr}</summary>

*${totalGzipItems} files with content-based hashes (individual files not comparable between builds)*

</details>
`
  }

  groupTable += `| **Total** | **${prettyBytes(mainRepoTotal)}** | **${prettyBytes(diffRepoTotal)}** | ${totalChangeStr} |\n`

  return `<details>
<summary>${displayName}</summary>

${groupTable}
</details>
`
}

function generateBundleSizeSection(result, tableHead) {
  let content = ''

  // Collect all group keys from the result
  const allGroupKeys = new Set([
    ...Object.keys(result.mainRepoStats || {}),
    ...Object.keys(result.diffRepoStats || {}),
  ])

  // Organize groups by bundler and category
  const bundlerGroups = {}
  const nonBundlerGroups = { client: [], server: [], shared: [], other: [] }

  for (const groupKey of allGroupKeys) {
    if (groupKey === 'General' || groupKey === benchTitle) continue

    const bundler = getBundlerFromGroupKey(groupKey)
    const baseGroup = getBaseGroupName(groupKey)

    if (bundler) {
      if (!bundlerGroups[bundler.name]) {
        bundlerGroups[bundler.name] = { icon: bundler.icon, groups: [] }
      }
      bundlerGroups[bundler.name].groups.push(groupKey)
    } else {
      // Categorize non-bundler groups
      if (BASE_BUNDLE_GROUPS.client.includes(baseGroup)) {
        nonBundlerGroups.client.push(groupKey)
      } else if (BASE_BUNDLE_GROUPS.server.includes(baseGroup)) {
        nonBundlerGroups.server.push(groupKey)
      } else if (BASE_BUNDLE_GROUPS.shared.includes(baseGroup)) {
        nonBundlerGroups.shared.push(groupKey)
      } else {
        nonBundlerGroups.other.push(groupKey)
      }
    }
  }

  // Generate content for bundler-specific groups
  for (const [bundlerName, bundlerData] of Object.entries(bundlerGroups)) {
    let bundlerContent = ''
    let hasAny = false

    // Organize bundler groups by category
    // Skip shared groups - they'll be rendered in their own section
    const categorizedGroups = { client: [], server: [], other: [] }
    for (const groupKey of bundlerData.groups) {
      const baseGroup = getBaseGroupName(groupKey)
      // Skip shared groups (like Next Runtimes) - they're bundler-independent
      if (BASE_BUNDLE_GROUPS.shared.includes(baseGroup)) {
        // Move to nonBundlerGroups.shared for unified rendering
        if (!nonBundlerGroups.shared.includes(groupKey)) {
          nonBundlerGroups.shared.push(groupKey)
        }
        continue
      }
      if (BASE_BUNDLE_GROUPS.client.includes(baseGroup)) {
        categorizedGroups.client.push(groupKey)
      } else if (BASE_BUNDLE_GROUPS.server.includes(baseGroup)) {
        categorizedGroups.server.push(groupKey)
      } else {
        categorizedGroups.other.push(groupKey)
      }
    }

    // Client bundles
    if (categorizedGroups.client.length > 0) {
      let clientContent = ''
      for (const groupKey of categorizedGroups.client) {
        const groupContent = generateBundleGroup(groupKey, result, tableHead)
        if (groupContent) {
          hasAny = true
          clientContent += groupContent
        }
      }
      if (clientContent) {
        bundlerContent += `**Client**\n${clientContent}\n`
      }
    }

    // Server bundles
    if (categorizedGroups.server.length > 0) {
      let serverContent = ''
      for (const groupKey of categorizedGroups.server) {
        const groupContent = generateBundleGroup(groupKey, result, tableHead)
        if (groupContent) {
          hasAny = true
          serverContent += groupContent
        }
      }
      if (serverContent) {
        bundlerContent += `**Server**\n${serverContent}\n`
      }
    }

    // Other bundles (collapsed)
    if (categorizedGroups.other.length > 0) {
      let otherContent = ''
      for (const groupKey of categorizedGroups.other) {
        const groupContent = generateBundleGroup(groupKey, result, tableHead)
        if (groupContent) {
          hasAny = true
          otherContent += groupContent
        }
      }
      if (otherContent) {
        bundlerContent += `<details>\n<summary><strong>Build Details</strong></summary>\n\n${otherContent}</details>\n\n`
      }
    }

    if (hasAny) {
      content += `### ${bundlerData.icon} ${bundlerName}\n\n${bundlerContent}`
    }
  }

  // Handle any non-bundler-specific groups
  for (const [categoryKey, groups] of Object.entries(nonBundlerGroups)) {
    if (groups.length === 0) continue

    let categoryContent = ''
    let hasAny = false

    // For shared groups, deduplicate by base name (e.g., "Next Runtimes (Turbopack)"
    // and "Next Runtimes (Webpack)" should only show once)
    const seenBaseGroups = new Set()

    for (const groupKey of groups) {
      const baseGroup = getBaseGroupName(groupKey)

      // Skip if we've already rendered this base group
      if (categoryKey === 'shared' && seenBaseGroups.has(baseGroup)) {
        continue
      }
      seenBaseGroups.add(baseGroup)

      const groupContent = generateBundleGroup(groupKey, result, tableHead)
      if (groupContent) {
        hasAny = true
        categoryContent += groupContent
      }
    }

    if (hasAny) {
      const titles = {
        client: '📦 Client',
        server: '🖥️ Server',
        shared: '🔄 Shared (bundler-independent)',
        other: '🔧 Other',
      }
      content += `### ${titles[categoryKey]}\n\n${categoryContent}`
    }
  }

  return content ? `## Bundle Sizes\n\n${content}` : ''
}

function generateDiffsSection(result) {
  if (!result.diffs || Object.keys(result.diffs).length === 0) return ''

  const diffKeys = Object.keys(result.diffs)
  const diffCount = diffKeys.length

  // Just show count and list of changed files, keep diffs collapsed
  let content = `<details>\n<summary><strong>📝 Changed Files</strong> (${diffCount} file${diffCount === 1 ? '' : 's'})</summary>\n\n`

  // List files that changed
  content += '**Files with changes:**\n'
  for (const itemKey of diffKeys.slice(0, 20)) {
    content += `- \`${shortenLabel(itemKey)}\`\n`
  }
  if (diffKeys.length > 20) {
    content += `- ... and ${diffKeys.length - 20} more\n`
  }

  // Show actual diffs in nested collapsed sections
  content += '\n<details>\n<summary>View diffs</summary>\n\n'
  for (const [itemKey, diff] of Object.entries(result.diffs)) {
    content += `<details>\n<summary>${shortenLabel(itemKey)}</summary>\n\n`
    if (diff.length > 36000) {
      content += 'Diff too large to display'
    } else {
      content += `\`\`\`diff\n${diff}\n\`\`\``
    }
    content += '\n</details>\n'
  }
  content += '</details>\n'

  content += '</details>\n\n'
  return content
}

// Find the most recent value for a metric in the KV history.
function getLatestHistoricalValue(history, metricKey) {
  if (!history?.entries?.length) return undefined
  for (let i = history.entries.length - 1; i >= 0; i--) {
    const val = history.entries[i].metrics?.[metricKey]
    if (typeof val === 'number') return val
  }
  return undefined
}

// Generate the dedicated Native Binary section shown after Bundle Sizes.
function generateNativeBinarySection(mainStats, diffStats, history) {
  const mainGeneral = mainStats?.General || {}
  const diffGeneral = diffStats?.General || {}

  const mainVal = mainGeneral.swcBinarySize
  const diffVal = diffGeneral.swcBinarySize

  // Nothing to show if we don't have any measurement
  if (typeof mainVal !== 'number' && typeof diffVal !== 'number') return ''

  const mainStr = prettify(mainVal, 'bytes')
  const diffStr = prettify(diffVal, 'bytes')
  const change = formatChange(mainVal, diffVal, 'bytes', 'swcBinarySize')
  const histValues = getHistoricalValues(history, 'swcBinarySize')
  const sparkline = generateTrendBar(histValues)

  const hasTrend = Boolean(sparkline)
  const header = hasTrend
    ? `| Metric | Canary | PR | Change | Trend |
|:-------|-------:|---:|-------:|:-----:|`
    : `| Metric | Canary | PR | Change |
|:-------|-------:|---:|-------:|`

  const row = hasTrend
    ? `| SWC Binary Size | ${mainStr} | ${diffStr} | ${change.text} | ${sparkline} |`
    : `| SWC Binary Size | ${mainStr} | ${diffStr} | ${change.text} |`

  return `<details>
<summary><strong>🦀 Native Binary</strong></summary>

Size of the native SWC binary (\`packages/next-swc/native/*.node\`). The Canary column is the most recent value recorded on the canary branch.

${header}
${row}

</details>

`
}

function generatePrTarballSection(actionInfo) {
  if (actionInfo.isRelease || !actionInfo.commitId) return ''

  return `<details>
<summary><strong>📎 Tarball URL</strong></summary>

\`\`\`
${actionInfo.previewBuildsBaseUrl}/commits/${actionInfo.commitId}/next
\`\`\`

</details>

`
}

// ============================================================================
// Main Export
// ============================================================================

// Hidden marker to identify stats comments (invisible in rendered markdown)
const STATS_COMMENT_MARKER = '<!-- __NEXT_STATS_COMMENT__ -->'

module.exports = async function addComment(
  results = [],
  actionInfo,
  statsConfig
) {
  // Load historical data
  const history = await loadHistory()

  // Build the comment with hidden marker for identification
  let comment = `${STATS_COMMENT_MARKER}\n## ${
    actionInfo.isRelease
      ? statsConfig.commentReleaseHeading || 'Stats from current release'
      : statsConfig.commentHeading || 'Stats from current PR'
  }\n\n`

  const tableHead = `| | Canary | PR | Change |\n|:--|--:|--:|--:|\n`

  for (let i = 0; i < results.length; i++) {
    const result = results[i]
    const isLastResult = i === results.length - 1

    // The native SWC binary is shared between the canary and PR checkouts in
    // a single run (the workflow downloads it once and copies it into both),
    // so the in-run "canary" value is identical to the PR value. Override the
    // canary baseline with the last recorded value from KV history so the
    // diff is meaningful. Canary runs skip this and keep the measured value.
    if (!actionInfo.isRelease && result.mainRepoStats?.General) {
      const historicalSwcSize = getLatestHistoricalValue(
        history,
        'swcBinarySize'
      )
      if (typeof historicalSwcSize === 'number') {
        result.mainRepoStats.General.swcBinarySize = historicalSwcSize
      } else {
        // No history yet — hide the canary value so the table renders N/A
        delete result.mainRepoStats.General.swcBinarySize
      }
    }

    // Add summary showing only significant changes (not collapsed)
    if (i === 0) {
      comment += generateChangeSummary(
        result.mainRepoStats,
        result.diffRepoStats,
        history
      )
    }

    // Add performance section (collapsed by default)
    const perfSection = generatePerformanceSection(
      result.mainRepoStats,
      result.diffRepoStats,
      history
    )
    if (perfSection) {
      comment += `<details>\n<summary><strong>📊 All Metrics</strong></summary>\n\n${perfSection}</details>\n\n`
    }

    // Add bundle sizes (collapsed by default)
    const bundleSection = generateBundleSizeSection(result, tableHead)
    if (bundleSection) {
      comment += `<details>\n<summary><strong>📦 Bundle Sizes</strong></summary>\n\n${bundleSection}</details>\n\n`
    }

    // Add native binary size section (not collapsed, small)
    comment += generateNativeBinarySection(
      result.mainRepoStats,
      result.diffRepoStats,
      history
    )

    // Add diffs (already collapsed)
    comment += generateDiffsSection(result)

    if (!isLastResult) {
      comment += '<hr/>\n\n'
    }
  }

  comment += generatePrTarballSection(actionInfo)

  // Save canary stats to history (only for releases, not PR comparisons)
  // This ensures we only track official canary metrics, not PR-specific data
  if (results.length > 0 && actionInfo.isRelease && actionInfo.commitId) {
    const mainStats = results[0].mainRepoStats
    if (mainStats?.General) {
      // Compute aggregate totals for each bundle group
      const bundleTotals = computeBundleGroupTotals(mainStats)

      const entry = {
        commitId: actionInfo.commitId,
        timestamp: new Date().toISOString(),
        metrics: {
          ...mainStats.General, // Performance metrics
          ...bundleTotals, // Bundle size totals
        },
      }
      await saveToHistory(entry)
    }
  }

  // Output locally or post to GitHub
  if (process.env.LOCAL_STATS) {
    const statsPath = path.resolve('pr-stats.md')
    await fs.writeFile(statsPath, comment)
    console.log(`Output PR stats to ${statsPath}`)
  } else {
    logger('\n--stats start--\n', comment, '\n--stats end--\n')
  }

  if (
    actionInfo.customCommentEndpoint ||
    (actionInfo.githubToken && actionInfo.commentEndpoint)
  ) {
    const body = {
      body: comment,
      ...(!actionInfo.githubToken
        ? {
            isRelease: actionInfo.isRelease,
            commitId: actionInfo.commitId,
            issueId: actionInfo.issueId,
          }
        : {}),
    }

    if (actionInfo.customCommentEndpoint) {
      logger(`Using body ${JSON.stringify({ ...body, body: 'OMITTED' })}`)
    }

    try {
      // Try to find existing stats comment to update
      let existingCommentId = null
      const commentHeading =
        statsConfig.commentHeading || 'Stats from current PR'

      if (actionInfo.githubToken && actionInfo.commentEndpoint) {
        try {
          const existingRes = await fetch(actionInfo.commentEndpoint, {
            headers: {
              Authorization: `bearer ${actionInfo.githubToken}`,
            },
          })

          if (existingRes.ok) {
            const comments = await existingRes.json()
            // Find comment with our hidden marker, or fall back to heading match
            // The marker ensures we only update our own stats comments
            const existing = comments.find(
              (c) =>
                c.body &&
                (c.body.includes(STATS_COMMENT_MARKER) ||
                  // Legacy fallback: match by heading (for comments before marker was added)
                  ((c.body.startsWith(`## ${commentHeading}`) ||
                    c.body.startsWith(`# ${commentHeading}`)) &&
                    c.body.includes('Canary') &&
                    c.body.includes('Change')))
            )
            if (existing) {
              existingCommentId = existing.id
              logger(`Found existing comment ${existingCommentId} to update`)
            }
          }
        } catch (e) {
          logger.error('Failed to fetch existing comments:', e)
        }
      }

      // Update existing or create new
      let endpoint = actionInfo.commentEndpoint
      let method = 'POST'

      if (existingCommentId && actionInfo.githubToken) {
        // GitHub API: PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}
        endpoint = actionInfo.commentEndpoint.replace(
          /\/issues\/\d+\/comments$/,
          `/issues/comments/${existingCommentId}`
        )
        method = 'PATCH'
        logger(`Updating existing comment at ${endpoint}`)
      } else {
        logger(`Creating new comment at ${endpoint}`)
      }

      const res = await fetch(endpoint, {
        method,
        headers: {
          ...(actionInfo.githubToken
            ? {
                Authorization: `bearer ${actionInfo.githubToken}`,
              }
            : {
                'content-type': 'application/json',
              }),
        },
        body: JSON.stringify(body),
      })

      if (!res.ok) {
        logger.error(`Failed to ${method} results ${res.status}`)
        try {
          logger.error(await res.text())
        } catch (_) {
          /* no-op */
        }
      } else {
        logger(
          `Successfully ${method === 'PATCH' ? 'updated' : 'posted'} results`
        )
      }
    } catch (err) {
      logger.error(`Error occurred posting results`, err)
    }
  } else {
    logger(
      `Not posting results`,
      actionInfo.githubToken ? 'No comment endpoint' : 'no GitHub token'
    )
  }
}
Quest for Codev2.0.0
/
SIGN IN