OLD | NEW |
(Empty) | |
| 1 #!/usr/bin/env python |
| 2 |
| 3 # This file is part of Adblock Plus <https://adblockplus.org/>, |
| 4 # Copyright (C) 2006-present eyeo GmbH |
| 5 # |
| 6 # Adblock Plus is free software: you can redistribute it and/or modify |
| 7 # it under the terms of the GNU General Public License version 3 as |
| 8 # published by the Free Software Foundation. |
| 9 # |
| 10 # Adblock Plus is distributed in the hope that it will be useful, |
| 11 # but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 13 # GNU General Public License for more details. |
| 14 # |
| 15 # You should have received a copy of the GNU General Public License |
| 16 # along with Adblock Plus. If not, see <http://www.gnu.org/licenses/>. |
| 17 |
| 18 """Prepare a dependency update. |
| 19 |
| 20 This script executes the automatable work which needs to be done for a |
| 21 dependency update and provides additional information, i.e. a complete |
| 22 diff of imported changes, as well as related integration notes. |
| 23 """ |
| 24 |
| 25 from __future__ import print_function, unicode_literals |
| 26 |
| 27 import argparse |
| 28 import io |
| 29 import json |
| 30 import logging |
| 31 import os |
| 32 import re |
| 33 import subprocess |
| 34 try: |
| 35 from urllib import urlopen |
| 36 except ImportError: |
| 37 from urllib.request import urlopen |
| 38 |
| 39 import jinja2 |
| 40 |
| 41 from src.vcs import Vcs |
| 42 |
| 43 logging.basicConfig() |
| 44 logger = logging.getLogger('eyeo-depup') |
| 45 |
| 46 |
| 47 class DepUpdate(object): |
| 48 """The main class used to process dependency updates. |
| 49 |
| 50 TODO: CLARIFY ME! |
| 51 |
| 52 """ |
| 53 |
| 54 VCS_EXECUTABLE = ('hg', '--config', 'defaults.log=', '--config', |
| 55 'defaults.pull=') |
| 56 ISSUE_NUMBER_REGEX = re.compile(r'\b(issue|fixes)\s+(\d+)\b', re.I) |
| 57 NOISSUE_REGEX = re.compile(r'^noissue\b', re.I) |
| 58 |
| 59 def __init__(self, *args): |
| 60 """Construct a DepUpdate object. |
| 61 |
| 62 During initialization, DepUpdate will invoke the appropriate VCS to |
| 63 fetch a list of changes, parse them and (if not otherwise specified) |
| 64 get the matching revisions from the mirrored repository. |
| 65 |
| 66 Parameters: *args - Passed down to the argparse.ArgumentParser instance |
| 67 |
| 68 """ |
| 69 self._cwd = os.getcwd() |
| 70 |
| 71 self.root_repo = Vcs.factory(self._cwd) |
| 72 |
| 73 self._base_revision = None |
| 74 self._parsed_changes = None |
| 75 self.arguments = None |
| 76 |
| 77 self._dep_config = None |
| 78 |
| 79 default_template = os.path.join( |
| 80 os.path.dirname(os.path.realpath(__file__)), 'templates', |
| 81 'default.trac') |
| 82 |
| 83 # Initialize and run the internal argument parser |
| 84 self._make_arguments(default_template, *args) |
| 85 |
| 86 # Check if root repository is dirty |
| 87 if not self.root_repo.repo_is_clean(): |
| 88 logger.error('Your repository is dirty') |
| 89 exit(1) |
| 90 |
| 91 # Initialize the main VCS and the list of changes |
| 92 self._main_vcs = Vcs.factory(os.path.join(self._cwd, |
| 93 self.arguments.dependency)) |
| 94 self.changes = self._main_vcs.change_list(self.base_revision, |
| 95 self.arguments.new_revision) |
| 96 if len(self.changes) == 0: |
| 97 self.changes = self._main_vcs.change_list( |
| 98 self.arguments.new_revision, |
| 99 self.base_revision |
| 100 ) |
| 101 if len(self.changes) > 0: |
| 102 # reverse mode. Uh-oh. |
| 103 logger.warn('You are trying to downgrade the dependency!') |
| 104 |
| 105 self._main_vcs.enhance_changes_information( |
| 106 self.changes, |
| 107 os.path.join(self._mirror_location(), |
| 108 self.arguments.dependency), |
| 109 self.arguments.skip_mirror, |
| 110 ) |
| 111 |
| 112 def _make_arguments(self, default_template, *args): |
| 113 """Initialize the argument parser and store the arguments.""" |
| 114 parser = argparse.ArgumentParser( |
| 115 description=__doc__, |
| 116 formatter_class=argparse.RawDescriptionHelpFormatter) |
| 117 |
| 118 # First prepare all basic shared options |
| 119 options_parser = argparse.ArgumentParser(add_help=False) |
| 120 shared = options_parser.add_argument_group() |
| 121 shared.add_argument( |
| 122 'dependency', |
| 123 help=('The dependency to be updated, as specified in the ' |
| 124 'dependencies file.') |
| 125 ) |
| 126 shared.add_argument( |
| 127 '-r', '--revision', dest='new_revision', |
| 128 help=('The revision to update to. Defaults to the remote ' |
| 129 'master bookmark/branch. Must be accessible by the ' |
| 130 "dependency's vcs.") |
| 131 ) |
| 132 shared.add_argument( |
| 133 '-m', '--mirrored-repository', dest='local_mirror', |
| 134 help=('Path to the local copy of a mirrored repository. ' |
| 135 'Used to fetch the corresponding hash. If not ' |
| 136 'given, the source parsed from the dependencies file is ' |
| 137 'used.') |
| 138 ) |
| 139 |
| 140 # Shared options for non-commit commands |
| 141 advanced_parser = argparse.ArgumentParser(add_help=False) |
| 142 advanced = advanced_parser.add_argument_group() |
| 143 advanced.add_argument( |
| 144 '-f', '--filename', dest='filename', default=None, |
| 145 help=("When specified, write the subcommand's output to the " |
| 146 'given file, rather than to STDOUT.') |
| 147 ) |
| 148 advanced.add_argument( |
| 149 '-s', '--skip-mirror', action='store_true', dest='skip_mirror', |
| 150 help='Do not use any mirror.' |
| 151 ) |
| 152 advanced.add_argument( |
| 153 '-l', '--lookup-integration-notes', action='store_true', |
| 154 dest='lookup_inotes', default=False, |
| 155 help=('Search https://issues.adblockplus.org for integration ' |
| 156 'notes associated with the included issue IDs. The ' |
| 157 'results are written to STDERR. CAUTION: This is a very ' |
| 158 'network heavy operation.') |
| 159 ) |
| 160 |
| 161 subs = parser.add_subparsers( |
| 162 title='Subcommands', dest='action', metavar='[subcommand]', |
| 163 help=('Required, the actual command to be executed. Execute ' |
| 164 'run "<subcommand> -h" for more information.') |
| 165 ) |
| 166 |
| 167 # Add the command and options for creating a diff |
| 168 diff_parser = subs.add_parser( |
| 169 'diff', parents=[options_parser, advanced_parser], |
| 170 help='Create a unified diff of all changes', |
| 171 description=("Invoke the current repository's VCS to generate " |
| 172 'a diff, containing all changes made between two ' |
| 173 'revisions.')) |
| 174 diff_parser.add_argument( |
| 175 '-n', '--n-context-lines', dest='unified_lines', type=int, |
| 176 default=16, |
| 177 help=('Number of unified context lines to be added to the ' |
| 178 'diff. Defaults to 16 (Used only with -d/--diff).') |
| 179 ) |
| 180 |
| 181 # Add the command and options for creating an issue body |
| 182 issue_parser = subs.add_parser( |
| 183 'issue', parents=[options_parser, advanced_parser], |
| 184 help='Render an issue body', |
| 185 description=('Render an issue subject and an issue body, ' |
| 186 'according to the given template.')) |
| 187 issue_parser.add_argument( |
| 188 '-t', '--template', dest='tmpl_path', |
| 189 default=default_template, |
| 190 help=('The template to use. Defaults to the provided ' |
| 191 'default.trac (Used only with -i/--issue).') |
| 192 ) |
| 193 |
| 194 # Add the command for printing a list of changes |
| 195 subs.add_parser( |
| 196 'changes', parents=[options_parser, advanced_parser], |
| 197 help='Generate a list of commits between two revisions', |
| 198 description=('Generate a list of commit hashes and commit ' |
| 199 "messages between the dependency's current " |
| 200 'revision and a given new revision.')) |
| 201 |
| 202 # Add the command for changing and committing a dependency update |
| 203 commit_parser = subs.add_parser( |
| 204 'commit', help='Update and commit a dependency change', |
| 205 parents=[options_parser], |
| 206 description=('Rewrite and commit a dependency file to the new ' |
| 207 'revision. WARNING: This actually changes your ' |
| 208 "repository's history, use with care!")) |
| 209 commit_parser.add_argument( |
| 210 'issue_number', help=('The issue number, filed on ' |
| 211 'https://issues.adblockplus.org')) |
| 212 commit_parser.set_defaults(skip_mirror=False, lookup_inotes=False, |
| 213 filename=None) |
| 214 |
| 215 self.arguments = parser.parse_args(args if len(args) > 0 else None) |
| 216 |
| 217 @property |
| 218 def dep_config(self): |
| 219 """Provide the dependencies by using ensure_dependencies.read_dep(). |
| 220 |
| 221 Since this program is meant to be run inside a repository which uses |
| 222 the buildtools' dependency functionalities, we are sure that |
| 223 ensure_dependencies.py and dependencies exist. |
| 224 |
| 225 However, ensure_dependencies is currently only compatible with python2. |
| 226 Due to this we explicitly invoke a python2 interpreter to run our |
| 227 dependencies.py, which runs ensure_dependencies.read_deps() and returns |
| 228 the output as JSON data. |
| 229 """ |
| 230 if self._dep_config is None: |
| 231 dependencies_script = os.path.join( |
| 232 os.path.dirname(os.path.realpath(__file__)), 'dependencies.py') |
| 233 |
| 234 dep_json = subprocess.check_output( |
| 235 ['python2', dependencies_script] |
| 236 ).decode('utf-8') |
| 237 self._dep_config = json.loads(dep_json) |
| 238 return self._dep_config |
| 239 |
| 240 @property |
| 241 def base_revision(self): |
| 242 """Provide the current revision of the dependency to be processed.""" |
| 243 if self._base_revision is None: |
| 244 for key in ['*', self._main_vcs.EXECUTABLE]: |
| 245 rev = self.dep_config[self.arguments.dependency][key][1] |
| 246 if rev is not None: |
| 247 self._base_revision = rev |
| 248 break |
| 249 return self._base_revision |
| 250 |
| 251 def _parse_changes(self, changes): |
| 252 """Parse the changelist to issues / noissues.""" |
| 253 issue_ids = set() |
| 254 noissues = [] |
| 255 for change in changes: |
| 256 match = self.ISSUE_NUMBER_REGEX.search(change['message']) |
| 257 if match: |
| 258 issue_ids.add(match.group(2)) |
| 259 else: |
| 260 noissues.append(change) |
| 261 if not self.NOISSUE_REGEX.search(change['message']): |
| 262 msg = ( |
| 263 'warning: no issue reference in commit message: ' |
| 264 '"{message}" (commit {hg_hash} | {git_hash})\n' |
| 265 ).format(**change) |
| 266 logger.warn(msg) |
| 267 |
| 268 return issue_ids, noissues |
| 269 |
| 270 @property |
| 271 def parsed_changes(self): |
| 272 """Provide the list of changes, separated by issues and noissues. |
| 273 |
| 274 Returns a dictionary, containing the following two key/value pairs: |
| 275 'issue_ids': a list of issue IDs (as seen on |
| 276 https://issues.adblockplus.org/) |
| 277 'noissues': The remaining changes, with all original information (see |
| 278 DepUpdate.changes) which could not be associated with any |
| 279 issue. |
| 280 """ |
| 281 if self._parsed_changes is None: |
| 282 self._parsed_changes = {} |
| 283 issue_ids, noissues = self._parse_changes(self.changes) |
| 284 self._parsed_changes['issue_ids'] = issue_ids |
| 285 self._parsed_changes['noissues'] = noissues |
| 286 return self._parsed_changes |
| 287 |
| 288 def _possible_sources(self): |
| 289 root_conf = self._dep_config['_root'] |
| 290 config = self.dep_config[self.arguments.dependency] |
| 291 |
| 292 # The fallback / main source paths for a repository are given in the |
| 293 # dependencies file's _root section. |
| 294 keys = ['hg', 'git'] |
| 295 possible_sources = {} |
| 296 possible_sources.update({ |
| 297 key + '_root': root_conf[key] |
| 298 for key in keys |
| 299 }) |
| 300 |
| 301 # Any dependency may specify a custom source location. |
| 302 possible_sources.update({ |
| 303 key: source for key, source in [ |
| 304 (key, config.get(key, (None, None))[0]) for key in keys |
| 305 ] if source is not None |
| 306 }) |
| 307 |
| 308 return possible_sources |
| 309 |
| 310 def _mirror_location(self): |
| 311 possible_sources = self._possible_sources() |
| 312 mirror_ex = self._main_vcs._other_cls.EXECUTABLE |
| 313 |
| 314 # If the user specified a local mirror, use it. Otherwise use the |
| 315 # mirror, which was specified in the dependencies file. |
| 316 if self.arguments.local_mirror: |
| 317 mirror = self.arguments.local_mirror |
| 318 else: |
| 319 for key in [mirror_ex, mirror_ex + '_root']: |
| 320 if key in possible_sources: |
| 321 mirror = possible_sources[key] |
| 322 break |
| 323 return mirror |
| 324 |
| 325 def _make_dependencies_string(self, hg_source=None, hg_rev=None, |
| 326 git_source=None, git_rev=None, |
| 327 remote_name=None): |
| 328 dependency = '{} = {}'.format(self.arguments.dependency, |
| 329 remote_name or self.arguments.dependency) |
| 330 |
| 331 for prefix, rev, source in [(' hg:', hg_rev, hg_source), |
| 332 (' git:', git_rev, git_source)]: |
| 333 if rev is not None: |
| 334 dependency += prefix |
| 335 if source is not None: |
| 336 dependency += source + '@' |
| 337 dependency += rev |
| 338 |
| 339 return dependency |
| 340 |
| 341 def _update_dependencies_file(self): |
| 342 config = self.dep_config[self.arguments.dependency] |
| 343 |
| 344 remote_repository_name, none_hash_rev = config.get('*', (None, None)) |
| 345 hg_source, hg_rev = config.get('hg', (None, None)) |
| 346 git_source, git_rev = config.get('git', (None, None)) |
| 347 |
| 348 current_entry = self._make_dependencies_string( |
| 349 hg_source, hg_rev, git_source, git_rev, remote_repository_name |
| 350 ) |
| 351 |
| 352 new_entry = self._make_dependencies_string( |
| 353 hg_source, self.changes[0]['hg_hash'], git_source, |
| 354 self.changes[0]['git_hash'], remote_repository_name |
| 355 ) |
| 356 |
| 357 dependency_path = os.path.join(self._cwd, 'dependencies') |
| 358 with io.open(dependency_path, 'r', encoding='utf-8') as fp: |
| 359 current_deps = fp.read() |
| 360 with io.open(dependency_path, 'w', encoding='utf-8') as fp: |
| 361 fp.write(current_deps.replace(current_entry, new_entry)) |
| 362 |
| 363 def _update_copied_code(self): |
| 364 subprocess.check_output( |
| 365 ['python2', 'ensure_dependencies.py'], |
| 366 cwd=self._cwd |
| 367 ) |
| 368 |
| 369 def build_diff(self): |
| 370 """Generate a unified diff of all changes.""" |
| 371 return self._main_vcs.merged_diff(self.base_revision, |
| 372 self.arguments.new_revision, |
| 373 self.arguments.unified_lines) |
| 374 |
| 375 def build_issue(self): |
| 376 """Process all changes and render an issue.""" |
| 377 context = {} |
| 378 context['repository'] = self.arguments.dependency |
| 379 context['issue_ids'] = self.parsed_changes['issue_ids'] |
| 380 context['noissues'] = self.parsed_changes['noissues'] |
| 381 context['hg_hash'] = self.changes[0]['hg_hash'] |
| 382 context['git_hash'] = self.changes[0]['git_hash'] |
| 383 context['raw_changes'] = self.changes |
| 384 |
| 385 path, filename = os.path.split(self.arguments.tmpl_path) |
| 386 |
| 387 return jinja2.Environment( |
| 388 loader=jinja2.FileSystemLoader(path or './') |
| 389 ).get_template(filename).render(context) |
| 390 |
| 391 def lookup_integration_notes(self): |
| 392 """Search for any "Integration notes" mentions at the issue-tracker. |
| 393 |
| 394 Cycle through the list of issue IDs and search for "Integration Notes" |
| 395 in the associated issue on https://issues.adblockplus.org. If found, |
| 396 write the corresponding url to STDERR. |
| 397 """ |
| 398 # Let logger show INFO-level messages |
| 399 logger.setLevel(logging.INFO) |
| 400 integration_notes_regex = re.compile(r'Integration\s*notes', re.I) |
| 401 |
| 402 def from_url(issue_url): |
| 403 html = '' |
| 404 content = urlopen(issue_url) |
| 405 |
| 406 for line in content: |
| 407 html += line.decode('utf-8') |
| 408 return html |
| 409 |
| 410 for issue_id in self.parsed_changes['issue_ids']: |
| 411 issue_url = 'https://issues.adblockplus.org/ticket/' + issue_id |
| 412 html = from_url(issue_url) |
| 413 if not integration_notes_regex.search(html): |
| 414 continue |
| 415 |
| 416 logger.info('Integration notes found: ' + issue_url) |
| 417 |
| 418 def build_changes(self): |
| 419 """Write a descriptive list of the changes to STDOUT.""" |
| 420 return os.linesep.join( |
| 421 [( |
| 422 '( hg:{hg_hash} | git:{git_hash} ) : {message} (by {author})' |
| 423 ).format(**change) for change in self.changes] |
| 424 ) |
| 425 |
| 426 def commit_update(self): |
| 427 """Commit the new dependency and potentially updated files.""" |
| 428 commit_msg = 'Issue {} - Update {} to {}'.format( |
| 429 self.arguments.issue_number, self.arguments.dependency, |
| 430 ' / '.join((self.changes[0]['hg_hash'], |
| 431 self.changes[0]['git_hash']))) |
| 432 try: |
| 433 self._update_dependencies_file() |
| 434 self._update_copied_code() |
| 435 self.root_repo.commit_changes(commit_msg) |
| 436 |
| 437 return commit_msg |
| 438 except subprocess.CalledProcessError: |
| 439 self._main_vcs.undo_changes() |
| 440 logger.error('Could not safely commit the changes. Reverting.') |
| 441 |
| 442 def __call__(self): |
| 443 """Let this class's objects be callable, run all desired tasks.""" |
| 444 action_map = { |
| 445 'diff': self.build_diff, |
| 446 'changes': self.build_changes, |
| 447 'issue': self.build_issue, |
| 448 'commit': self.commit_update, |
| 449 } |
| 450 |
| 451 if len(self.changes) == 0: |
| 452 print('NO CHANGES FOUND. You are either trying to update to a ' |
| 453 'revision, which the dependency already is at - or ' |
| 454 'something went wrong while executing the vcs.') |
| 455 return |
| 456 |
| 457 if self.arguments.lookup_inotes: |
| 458 self.lookup_integration_notes() |
| 459 |
| 460 output = action_map[self.arguments.action]() |
| 461 if self.arguments.filename is not None: |
| 462 with io.open(self.arguments.filename, 'w', encoding='utf-8') as fp: |
| 463 fp.write(output) |
| 464 print('Output writen to ' + self.arguments.filename) |
| 465 else: |
| 466 print(output) |
OLD | NEW |