chromium/tools/clang/scripts/dashboard.py

#!/usr/bin/env python3
# Copyright 2023 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import argparse
import json
import os
import re
import subprocess
import sys
import time
import urllib.request

THIS_DIR = os.path.abspath(os.path.dirname(__file__))
CHROMIUM_REPO = os.path.abspath(os.path.join(THIS_DIR, '..', '..', '..'))
LLVM_REPO = ''  # This gets filled in by main().

# This script produces the dashboard at
# https://commondatastorage.googleapis.com/chromium-browser-clang/toolchain-dashboard.html
#
# Usage:
#
# ./dashboard.py > /tmp/toolchain-dashboard.html
# gsutil.py cp -a public-read /tmp/toolchain-dashboard.html gs://chromium-browser-clang/

#TODO: Add Rust and libc++ graphs.
#TODO: Plot 30-day moving averages.
#TODO: Overview with current age of each toolchain component.
#TODO: Tables of last N rolls for each component.
#TODO: Link to next roll bug, count of blockers, etc.


def timestamp_to_str(timestamp):
  '''Return a string representation of a Unix timestamp.'''
  return time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(timestamp))


def get_git_timestamp(repo, revision):
  '''Get the Unix timestamp of a git commit.'''
  out = subprocess.check_output([
      'git', '-C', repo, 'show', '--date=unix', '--pretty=fuller', '--no-patch',
      revision
  ]).decode('utf-8')
  DATE_RE = re.compile(r'^CommitDate: (\d+)$', re.MULTILINE)
  m = DATE_RE.search(out)
  return int(m.group(1))


svn2git_dict = None


def svn2git(svn_rev):
  global svn2git_dict
  if not svn2git_dict:
    # The JSON was generated with:
    # $ ( echo '{' && git rev-list 40c47680eb2a1cb9bb7f8598c319335731bd5204 | while read commit ; do SVNREV=$(git log --format=%B -n 1 $commit | grep '^llvm-svn: [0-9]*$' | awk '{print $2 }') ; [[ ! -z '$SVNREV' ]] && echo "\"$SVNREV\": \"$commit\"," ; done && echo '}' ) | tee /tmp/llvm_svn2git.json
    # and manually removing the trailing comma of the last entry.
    with urllib.request.urlopen(
        'https://commondatastorage.googleapis.com/chromium-browser-clang/llvm_svn2git.json'
    ) as url:
      svn2git_dict = json.load(url)
    # For branch commits, use the most recent commit to main instead.
    svn2git_dict['324578'] = '93505707b6d3ec117e555c5a48adc2cc56470e38'
    svn2git_dict['149886'] = '60fc2425457f43f38edf5b310551f996f4f42df8'
    svn2git_dict['145240'] = '12330650f843cf7613444e345a4ecfcf06923761'
  return svn2git_dict[svn_rev]


def clang_rolls():
  '''Return a dict from timestamp to clang revision rolled in at that time.'''
  FIRST_ROLL = 'd78457ce2895e5b98102412983a979f1896eca90'
  log = subprocess.check_output([
      'git', '-C', CHROMIUM_REPO, 'log', '--date=unix', '--pretty=fuller', '-p',
      f'{FIRST_ROLL}..origin/main', '--', 'tools/clang/scripts/update.py',
      'tools/clang/scripts/update.sh'
  ]).decode('utf-8')

  # AuthorDate is when a commit was first authored; CommitDate (part of
  # --pretty=fuller) is when a commit was last updated. We use the latter since
  # it's more likely to reflect when the commit become part of upstream.
  DATE_RE = re.compile(r'^CommitDate: (\d+)$')
  VERSION_RE = re.compile(
      r'^\+CLANG_REVISION = \'llvmorg-\d+-init-\d+-g([0-9a-f]+)\'$')
  VERSION_RE_OLD = re.compile(r'^\+CLANG_REVISION = \'([0-9a-f]{10,})\'$')
  # +CLANG_REVISION=125186
  VERSION_RE_SVN = re.compile(r'^\+CLANG_REVISION ?= ?\'?(\d{1,6})\'?$')

  rolls = {}
  date = None
  for line in log.splitlines():
    m = DATE_RE.match(line)
    if m:
      date = int(m.group(1))
      next

    rev = None
    if m := VERSION_RE.match(line):
      rev = m.group(1)
    elif m := VERSION_RE_OLD.match(line):
      rev = m.group(1)
    elif m := VERSION_RE_SVN.match(line):
      rev = svn2git(m.group(1))

    if rev:
      assert (date)
      rolls[date] = rev
      date = None

  return rolls


def clang_roll_ages(rolls):
  '''Given a dict from timestamps to clang revisions, return a list of pairs
    of timestamp string and clang revision age in days at that timestamp.'''

  ages = []
  def add(timestamp, rev):
    ages.append((timestamp_to_str(timestamp),
                 (timestamp - get_git_timestamp(LLVM_REPO, rev)) / (3600 * 24)))

  assert (rolls)
  prev_roll_rev = None
  for roll_time, roll_rev in sorted(rolls.items()):
    if prev_roll_rev:
      add(roll_time - 1, prev_roll_rev)
    add(roll_time, roll_rev)
    prev_roll_rev = roll_rev
  add(time.time(), prev_roll_rev)

  return ages


def print_dashboard():
  rolls = clang_rolls()
  ages = clang_roll_ages(rolls)

  print('''
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>Chromium Toolchain Dashboard</title>
    <script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
    <script type="text/javascript">
      google.charts.load('current', {'packages':['corechart', 'controls']});
      google.charts.setOnLoadCallback(drawChart);

      function drawChart() {
        var data = google.visualization.arrayToDataTable([
['Date', 'Clang'],''')

  for time_str, age in ages:
    print(f'[new Date("{time_str}"), {age:.1f}],')

  print(''']);

        var dashboard = new google.visualization.Dashboard(document.getElementById('dashboard'));
        var filter = new google.visualization.ControlWrapper({
          controlType: 'ChartRangeFilter',
          containerId: 'filter',
          options: {
            filterColumnIndex: 0,
            ui: { chartOptions: { interpolateNulls: true, } },
          },
          state: {
            // Start showing roughly the last 6 months.
            range: { start: new Date(Date.now() - 1000 * 3600 * 24 * 31 * 6), },
          },
        });
        var chart = new google.visualization.ChartWrapper({
          chartType: 'LineChart',
          containerId: 'chart',
          options: {
            width: 900,
            title: 'Chromium Toolchain Age Over Time',
            legend: 'top',
            vAxis: { title: 'Age (days)' },
            interpolateNulls: true,
            chartArea: {'width': '80%', 'height': '80%'},
          }
        });
        dashboard.bind(filter, chart);
        dashboard.draw(data);
      }
    </script>
  </head>
  <body>
    <h1>Chromium Toolchain Dashboard (go/chrome-clang-dash)</h1>''')

  print(f'<p>Last updated: {timestamp_to_str(time.time())} UTC</p>')

  print('''
    <div id="dashboard">
      <div id="chart" style="width: 900px; height: 500px"></div>
      <div id="filter" style="width: 900px; height: 50px"></div>
    </div>
  </body>
</html>
''')


def main():
  parser = argparse.ArgumentParser(
      description='Generate Chromium toolchain dashboard.')
  parser.add_argument('--llvm-dir',
                      help='LLVM repository directory.',
                      default=os.path.join(CHROMIUM_REPO, '..', '..',
                                           'llvm-project'))
  args = parser.parse_args()

  global LLVM_REPO
  LLVM_REPO = args.llvm_dir
  if not os.path.isdir(os.path.join(LLVM_REPO, '.git')):
    print(f'Invalid LLVM repository path: {LLVM_REPO}')
    return 1

  print_dashboard()
  return 0


if __name__ == '__main__':
  sys.exit(main())