| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367 |
- #!/usr/bin/env python3
- #
- # Copyright (c) 2020 Project CHIP Authors
- #
- # Licensed under the Apache License, Version 2.0 (the "License");
- # you may not use this file except in compliance with the License.
- # You may obtain a copy of the License at
- #
- # http://www.apache.org/licenses/LICENSE-2.0
- #
- # Unless required by applicable law or agreed to in writing, software
- # distributed under the License is distributed on an "AS IS" BASIS,
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- # See the License for the specific language governing permissions and
- # limitations under the License.
- #
- import argparse
- import csv
- import datetime
- import io
- import logging
- import os
- import re
- import stat
- import subprocess
- import traceback
- import zipfile
- import coloredlogs
- import github
- import github_fetch_artifacts
- LOG_KEEP_DAYS = 3
- BINARY_KEEP_DAYS = 30
- # Count is reasonably large because each build has multiple artifacts
- # Currently (Sep 2020) each build has 4 artifacts:
- # gn-nrf, gn-linux, examples-esp32, example-nrf
- #
- # We should eventually remove the non-gn version to save space.
- BINARY_MAX_COUNT = 80
- class SectionChange:
- """Describes delta changes to a specific section"""
- def __init__(self, section, fileChange, vmChange):
- self.section = section
- self.fileChange = fileChange
- self.vmChange = vmChange
- class ComparisonResult:
- """Comparison results for an entire file"""
- def __init__(self, name):
- self.fileName = name
- self.sectionChanges = []
- SECTIONS_TO_WATCH = set(
- ['.rodata', '.text', '.flash.rodata', '.flash.text', '.bss', '.data'])
- def filesInDirectory(dirName):
- """Get all the file names in the specified directory."""
- for name in os.listdir(dirName):
- mode = os.stat(os.path.join(dirName, name)).st_mode
- if stat.S_ISREG(mode):
- yield name
- def writeFileBloatReport(f, baselineName, buildName):
- """Generate a bloat report diffing a baseline file with a build output file."""
- logging.info('Running bloaty diff between %s and %s',
- baselineName, buildName)
- f.write('Comparing %s and %s:\n\n' % (baselineName, buildName))
- result = subprocess.run(
- ['bloaty', '--csv', buildName, '--', baselineName],
- stdout=subprocess.PIPE,
- stderr=subprocess.STDOUT,
- )
- if result.returncode != 0:
- logging.warning('Bloaty execution failed: %d', result.returncode)
- f.write('BLOAT EXECUTION FAILED WITH CODE %d:\n' % result.returncode)
- content = result.stdout.decode('utf8')
- f.write(content)
- f.write('\n')
- result = ComparisonResult(os.path.basename(buildName))
- try:
- reader = csv.reader(io.StringIO(content))
- for row in reader:
- section, vm, f = row
- if (section in SECTIONS_TO_WATCH) or (vm not in ['0', 'vmsize']):
- result.sectionChanges.append(
- SectionChange(section, int(f), int(vm)))
- except Exception:
- pass
- return result
- def generateBloatReport(outputFileName,
- baselineDir,
- buildOutputDir,
- title='BLOAT REPORT'):
- """Generates a bloat report fo files between two diferent directories."""
- logging.info('Generating bloat diff report between %s and %s', baselineDir,
- buildOutputDir)
- with open(outputFileName, 'wt') as f:
- f.write(title + '\n\n')
- baselineNames = set([name for name in filesInDirectory(baselineDir)])
- outputNames = set([name for name in filesInDirectory(buildOutputDir)])
- baselineOnly = baselineNames - outputNames
- if baselineOnly:
- logging.warning(
- 'Some files only exist in the baseline: %r', baselineOnly)
- f.write('Files found only in the baseline:\n ')
- f.write('\n %s'.join(baselineOnly))
- f.write('\n\n')
- outputOnly = outputNames - baselineNames
- if outputOnly:
- logging.warning('Some files only exist in the build output: %r',
- outputOnly)
- f.write('Files found only in the build output:\n ')
- f.write('\n %s'.join(outputOnly))
- f.write('\n\n')
- results = []
- for name in (baselineNames & outputNames):
- results.append(
- writeFileBloatReport(f, os.path.join(baselineDir, name),
- os.path.join(buildOutputDir, name)))
- return results
- def sendFileAsPrComment(job_name, filename, gh_token, gh_repo, gh_pr_number,
- compare_results, base_sha):
- """Generates a PR comment containing the specified file content."""
- logging.info('Uploading report to "%s", PR %d', gh_repo, gh_pr_number)
- rawText = open(filename, 'rt').read()
- # a consistent title to help identify obsolete comments
- titleHeading = 'Size increase report for "{jobName}"'.format(
- jobName=job_name)
- api = github.Github(gh_token)
- repo = api.get_repo(gh_repo)
- pull = repo.get_pull(gh_pr_number)
- for comment in pull.get_issue_comments():
- if not comment.body.startswith(titleHeading):
- continue
- logging.info(
- 'Removing obsolete comment with heading "%s"', (titleHeading))
- comment.delete()
- if all(len(file.sectionChanges) == 0 for file in compare_results):
- logging.info('No results to report')
- return
- compareTable = 'File | Section | File | VM\n---- | ---- | ----- | ---- \n'
- for file in compare_results:
- for change in file.sectionChanges:
- compareTable += '{0} | {1} | {2} | {3}\n'.format(file.fileName,
- change.section,
- change.fileChange,
- change.vmChange)
- # NOTE: PRs are issues with attached patches, hence the API naming
- pull.create_issue_comment("""{title} from {baseSha}
- {table}
- <details>
- <summary>Full report output</summary>
- ```
- {rawReportText}
- ```
- </details>
- """.format(title=titleHeading, baseSha=base_sha, table=compareTable, rawReportText=rawText))
- def getPullRequestBaseSha(githubToken, githubRepo, pullRequestNumber):
- """Figure out the SHA for the base of a pull request"""
- api = github.Github(githubToken)
- repo = api.get_repo(githubRepo)
- pull = repo.get_pull(pullRequestNumber)
- return pull.base.sha
- def cleanDir(name):
- """Ensures a clean directory with the given name exists. Only handles files"""
- if os.path.exists(name):
- for fname in os.listdir(name):
- path = os.path.join(name, fname)
- if os.path.isfile(path):
- os.unlink(path)
- else:
- os.mkdir(name)
- def downloadArtifact(artifact, dirName):
- """Extract an artifact into a directory."""
- zipFile = zipfile.ZipFile(io.BytesIO(artifact.downloadBlob()), 'r')
- logging.info('Extracting zip file to %r' % dirName)
- zipFile.extractall(dirName)
- def main():
- """Main task if executed standalone."""
- parser = argparse.ArgumentParser(
- description='Fetch master build artifacts.')
- parser.add_argument(
- '--output-dir',
- type=str,
- default='.',
- help='Where to download the artifacts')
- parser.add_argument(
- '--github-api-token',
- type=str,
- help='Github API token to upload the report as a comment')
- parser.add_argument(
- '--github-repository', type=str, help='Repository to use for PR comments')
- parser.add_argument(
- '--log-level',
- default=logging.INFO,
- type=lambda x: getattr(logging, x),
- help='Configure the logging level.')
- args = parser.parse_args()
- # Ensures somewhat pretty logging of what is going on
- logging.basicConfig(
- level=args.log_level,
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
- coloredlogs.install()
- if not args.github_api_token:
- logging.error(
- 'Required arguments missing: github api token is required.')
- return
- # all known artifacts
- artifacts = [a for a in github_fetch_artifacts.getAllArtifacts(
- args.github_api_token, args.github_repository)]
- # process newest artifacts first
- artifacts.sort(key=lambda x: x.created_at, reverse=True)
- current_time = datetime.datetime.now()
- seen_names = set()
- pull_artifact_re = re.compile('^(.*)-pull-(\\d+)$')
- binary_count = 0
- for a in artifacts:
- # Ignore size reports; they are handled by a separate script.
- if a.name.startswith('Size,'):
- continue
- # logs cleanup after 3 days
- is_log = a.name.endswith('-logs')
- if not is_log:
- binary_count = binary_count + 1
- need_delete = False
- if (current_time - a.created_at).days > BINARY_KEEP_DAYS:
- # Do not keep binary builds forever
- need_delete = True
- elif not is_log and binary_count > BINARY_MAX_COUNT:
- # Keep a maximum number of binary packages
- need_delete = True
- elif is_log and (current_time - a.created_at).days > LOG_KEEP_DAYS:
- # Logs are kept even shorter
- need_delete = True
- if need_delete:
- logging.info('Old artifact: %s from %r' % (a.name, a.created_at))
- a.delete()
- continue
- if a.name.endswith('-logs'):
- # logs names are duplicate, however that is fine
- continue
- if a.name in seen_names:
- logging.info('Artifact name already seen before: %s' % a.name)
- a.delete()
- continue
- seen_names.add(a.name)
- m = pull_artifact_re.match(a.name)
- if not m:
- logging.info('Non-PR artifact found: %r from %r' %
- (a.name, a.created_at))
- continue
- prefix = m.group(1)
- pull_number = int(m.group(2))
- logging.info('Processing PR %s via artifact %r' %
- (pull_number, a.name))
- try:
- base_sha = getPullRequestBaseSha(
- args.github_api_token, args.github_repository, pull_number)
- base_artifact_name = '%s-%s' % (prefix, base_sha)
- base_artifacts = [
- v for v in artifacts if v.name == base_artifact_name]
- if len(base_artifacts) != 1:
- raise Exception('Did not find exactly one artifact for %s: %r' % (
- base_artifact_name, [v.name for v in base_artifacts]))
- b = base_artifacts[0]
- logging.info('Diff will be against artifact %r' % b.name)
- aOutput = os.path.join(args.output_dir, 'pull_artifact')
- bOutput = os.path.join(args.output_dir, 'master_artifact')
- cleanDir(aOutput)
- cleanDir(bOutput)
- downloadArtifact(a, aOutput)
- downloadArtifact(b, bOutput)
- report_name = os.path.join(aOutput, 'report.csv')
- results = generateBloatReport(report_name, bOutput, aOutput)
- sendFileAsPrComment(prefix, report_name, args.github_api_token,
- args.github_repository, pull_number, results, base_sha)
- # If running over a top level directory, ensure git sees no output
- cleanDir(aOutput)
- cleanDir(bOutput)
- # Output processed.
- a.delete()
- except Exception:
- tb = traceback.format_exc()
- logging.warning('Failed to process bloat report: %s', tb)
- if __name__ == '__main__':
- # execute only if run as a script
- main()
|