class Patch(Command): """Applies a specific patch from a RB server. The patch file indicated by the request id is downloaded from the server and then applied locally.""" name = "patch" author = "The Review Board Project" args = "<request-id>" option_list = [ Option("--diff-revision", dest="diff_revision", default=None, help="revision id of diff to be used as patch"), Option("--px", dest="px", default=None, help="numerical pX argument for patch"), Option("--server", dest="server", metavar="SERVER", config_key="REVIEWBOARD_URL", default=None, help="specify a different Review Board server to use"), Option("-d", "--debug", action="store_true", dest="debug", config_key="DEBUG", default=False, help="display debug output"), Option("--username", dest="username", metavar="USERNAME", config_key="USERNAME", default=None, help="user name to be supplied to the Review Board server"), Option("--password", dest="password", metavar="PASSWORD", config_key="PASSWORD", default=None, help="password to be supplied to the Review Board server"), ] def get_patch(self, request_id, diff_revision=None): """Given a review request ID and a diff revision, return the diff as a string, the used diff revision, and its basedir. If a diff revision is not specified, then this will look at the most recent diff. """ try: diffs = self.root_resource \ .get_review_requests() \ .get_item(request_id) \ .get_diffs() except APIError, e: die("Error getting diffs: %s" % (e)) # Use the latest diff if a diff revision was not given. # Since diff revisions start a 1, increment by one, and # never skip a number, the latest diff revisions number # should be equal to the number of diffs. if diff_revision is None: diff_revision = diffs.total_results try: diff = diffs.get_item(diff_revision) diff_body = diff.get_patch().data base_dir = diff.basedir except APIError: die('The specified diff revision does not exist.') return diff_body, diff_revision, base_dir
class Post(Command): """Create and update review requests.""" name = 'post' author = 'The Review Board Project' description = 'Uploads diffs to create and update review requests.' args = '[revisions]' GUESS_AUTO = 'auto' GUESS_YES = 'yes' GUESS_NO = 'no' GUESS_YES_INPUT_VALUES = (True, 'yes', 1, '1') GUESS_NO_INPUT_VALUES = (False, 'no', 0, '0') GUESS_CHOICES = (GUESS_AUTO, GUESS_YES, GUESS_NO) option_list = [ OptionGroup( name='Posting Options', description='Controls the behavior of a post, including what ' 'review request gets posted and how, and what ' 'happens after it is posted.', option_list=[ Option('-u', '--update', dest='update', action='store_true', default=False, help='Automatically determines the existing review ' 'request to update.', added_in='0.5.3'), Option('-r', '--review-request-id', dest='rid', metavar='ID', default=None, help='Specifies the existing review request ID to ' 'update.'), Option('-p', '--publish', dest='publish', action='store_true', default=False, config_key='PUBLISH', help='Publishes the review request immediately after ' 'posting.' '\n' 'All required fields must already be filled in ' 'on the review request or must be provided when ' 'posting.'), Option('-o', '--open', dest='open_browser', action='store_true', config_key='OPEN_BROWSER', default=False, help='Opens a web browser to the review request ' 'after posting.'), Option('--submit-as', dest='submit_as', metavar='USERNAME', config_key='SUBMIT_AS', default=None, help='The username to use as the author of the ' 'review request, instead of the logged in user.', extended_help=( "This is useful when used in a repository's " "post-commit script to update or create review " "requests. See :ref:`automating-rbt-post` for " "more information on this use case.")), Option('--change-only', dest='change_only', action='store_true', default=False, help='Updates fields from the change description, ' 'but does not upload a new diff ' '(Perforce/Plastic only).'), Option('--diff-only', dest='diff_only', action='store_true', default=False, help='Uploads a new diff, but does not update ' 'fields from the change description ' '(Perforce/Plastic only).'), ]), Command.server_options, Command.repository_options, OptionGroup( name='Review Request Field Options', description='Options for setting the contents of fields in the ' 'review request.', option_list=[ Option('-g', '--guess-fields', dest='guess_fields', action='store', config_key='GUESS_FIELDS', nargs='?', default=GUESS_AUTO, const=GUESS_YES, choices=GUESS_CHOICES, help='Equivalent to setting both --guess-summary ' 'and --guess-description.', extended_help=( 'This can optionally take a value to control the ' 'guessing behavior. See :ref:`guessing-behavior` ' 'for more information.')), Option('--guess-summary', dest='guess_summary', action='store', config_key='GUESS_SUMMARY', nargs='?', default=GUESS_AUTO, const=GUESS_YES, choices=GUESS_CHOICES, help='Generates the Summary field based on the ' 'commit messages (Bazaar/Git/Mercurial only).', extended_help=( 'This can optionally take a value to control the ' 'guessing behavior. See :ref:`guessing-behavior` ' 'for more information.')), Option('--guess-description', dest='guess_description', action='store', config_key='GUESS_DESCRIPTION', nargs='?', default=GUESS_AUTO, const=GUESS_YES, choices=GUESS_CHOICES, help='Generates the Description field based on the ' 'commit messages (Bazaar/Git/Mercurial only).', extended_help=( 'This can optionally take a value to control the ' 'guessing behavior. See :ref:`guessing-behavior` ' 'for more information.')), Option('--change-description', default=None, metavar='TEXT', help='A description of what changed in this update ' 'of the review request. This is ignored for new ' 'review requests.'), Option('--summary', dest='summary', metavar='TEXT', default=None, help='The new contents for the Summary field.'), Option('--description', dest='description', metavar='TEXT', default=None, help='The new contents for the Description field.'), Option('--description-file', dest='description_file', default=None, metavar='FILENAME', help='A text file containing the new contents for the ' 'Description field.'), Option('--testing-done', dest='testing_done', metavar='TEXT', default=None, help='The new contents for the Testing Done field.'), Option('--testing-done-file', dest='testing_file', default=None, metavar='FILENAME', help='A text file containing the new contents for the ' 'Testing Done field.'), Option('--branch', dest='branch', config_key='BRANCH', metavar='BRANCH', default=None, help='The branch the change will be committed on or ' 'affects. This is a free-form field and does not ' 'control any behavior.'), Option('--bugs-closed', dest='bugs_closed', metavar='BUG_ID[,...]', default=None, help='The comma-separated list of bug IDs closed.'), Option('--target-groups', dest='target_groups', config_key='TARGET_GROUPS', metavar='NAME[,...]', default=None, help='The names of the groups that should perform the ' 'review.'), Option('--target-people', dest='target_people', metavar='USERNAME[,...]', config_key='TARGET_PEOPLE', default=None, help='The usernames of the people who should perform ' 'the review.'), Option('--depends-on', dest='depends_on', config_key='DEPENDS_ON', metavar='ID[,...]', default=None, help='A comma-separated list of review request IDs ' 'that this review request will depend on.', added_in='0.6.1'), Option('--markdown', dest='markdown', action='store_true', config_key='MARKDOWN', default=False, help='Specifies if the summary and description should ' 'be interpreted as Markdown-formatted text.' '\n' 'This is only supported in Review Board 2.0+.', added_in='0.6'), ]), Command.diff_options, Command.perforce_options, Command.subversion_options, Command.tfs_options, ] def post_process_options(self): # -g implies --guess-summary and --guess-description if self.options.guess_fields: self.options.guess_fields = self.normalize_guess_value( self.options.guess_fields, '--guess-fields') self.options.guess_summary = self.options.guess_fields self.options.guess_description = self.options.guess_fields if self.options.revision_range: raise CommandError( 'The --revision-range argument has been removed. To post a ' 'diff for one or more specific revisions, pass those ' 'revisions as arguments. For more information, see the ' 'RBTools 0.6 Release Notes.') if self.options.svn_changelist: raise CommandError( 'The --svn-changelist argument has been removed. To use a ' 'Subversion changelist, pass the changelist name as an ' 'additional argument after the command.') # Only one of --description and --description-file can be used if self.options.description and self.options.description_file: raise CommandError('The --description and --description-file ' 'options are mutually exclusive.\n') # If --description-file is used, read that file if self.options.description_file: if os.path.exists(self.options.description_file): with open(self.options.description_file, 'r') as fp: self.options.description = fp.read() else: raise CommandError( 'The description file %s does not exist.\n' % self.options.description_file) # Only one of --testing-done and --testing-done-file can be used if self.options.testing_done and self.options.testing_file: raise CommandError('The --testing-done and --testing-done-file ' 'options are mutually exclusive.\n') # If --testing-done-file is used, read that file if self.options.testing_file: if os.path.exists(self.options.testing_file): with open(self.options.testing_file, 'r') as fp: self.options.testing_done = fp.read() else: raise CommandError('The testing file %s does not exist.\n' % self.options.testing_file) # If we have an explicitly specified summary, override # --guess-summary if self.options.summary: self.options.guess_summary = self.GUESS_NO else: self.options.guess_summary = self.normalize_guess_value( self.options.guess_summary, '--guess-summary') # If we have an explicitly specified description, override # --guess-description if self.options.description: self.options.guess_description = self.GUESS_NO else: self.options.guess_description = self.normalize_guess_value( self.options.guess_description, '--guess-description') # If we have an explicitly specified review request ID, override # --update if self.options.rid and self.options.update: self.options.update = False def normalize_guess_value(self, guess, arg_name): if guess in self.GUESS_YES_INPUT_VALUES: return self.GUESS_YES elif guess in self.GUESS_NO_INPUT_VALUES: return self.GUESS_NO elif guess == self.GUESS_AUTO: return guess else: raise CommandError('Invalid value "%s" for argument "%s"' % (guess, arg_name)) def get_repository_path(self, repository_info, api_root): """Get the repository path from the server. This will compare the paths returned by the SCM client with those one the server, and return the first match. """ if isinstance(repository_info.path, list): repositories = api_root.get_repositories(only_fields='path', only_links='') try: while True: for repo in repositories: if repo['path'] in repository_info.path: repository_info.path = repo['path'] raise StopIteration() repositories = repositories.get_next() except StopIteration: pass if isinstance(repository_info.path, list): error_str = [ 'There was an error creating this review request.\n', '\n', 'There was no matching repository path found on the server.\n', 'Unknown repository paths found:\n', ] for foundpath in repository_info.path: error_str.append('\t%s\n' % foundpath) error_str += [ 'Ask the administrator to add one of these repositories\n', 'to the Review Board server.\n', ] raise CommandError(''.join(error_str)) return repository_info.path def post_request(self, repository_info, repository, server_url, api_root, review_request_id=None, changenum=None, diff_content=None, parent_diff_content=None, commit_id=None, base_commit_id=None, submit_as=None, retries=3, base_dir=None): """Creates or updates a review request, and uploads a diff. On success the review request id and url are returned. """ supports_posting_commit_ids = \ self.tool.capabilities.has_capability('review_requests', 'commit_ids') if review_request_id: review_request = get_review_request( review_request_id, api_root, only_fields='absolute_url,bugs_closed,id,status', only_links='diffs,draft') if review_request.status == 'submitted': raise CommandError( 'Review request %s is marked as %s. In order to update ' 'it, please reopen the review request and try again.' % (review_request_id, review_request.status)) else: # No review_request_id, so we will create a new review request. try: request_data = {'repository': repository} if changenum: request_data['changenum'] = changenum elif commit_id and supports_posting_commit_ids: request_data['commit_id'] = commit_id if submit_as: request_data['submit_as'] = submit_as review_requests = api_root.get_review_requests( only_fields='', only_links='create') review_request = review_requests.create(**request_data) except APIError as e: if e.error_code == 204 and changenum: # Change number in use. rid = e.rsp['review_request']['id'] review_request = api_root.get_review_request( review_request_id=rid, only_fields='absolute_url,bugs_closed,id,status', only_links='diffs,draft') if not self.options.diff_only: review_request = review_request.update( changenum=changenum) else: raise CommandError('Error creating review request: %s' % e) if (not repository_info.supports_changesets or not self.options.change_only): try: diff_kwargs = { 'parent_diff': parent_diff_content, 'base_dir': base_dir, } if (base_commit_id and self.tool.capabilities.has_capability( 'diffs', 'base_commit_ids')): # Both the Review Board server and SCMClient support # base commit IDs, so pass that along when creating # the diff. diff_kwargs['base_commit_id'] = base_commit_id review_request.get_diffs(only_fields='').upload_diff( diff_content, **diff_kwargs) except APIError as e: error_msg = [ u'Error uploading diff\n\n', ] if e.error_code == 101 and e.http_status == 403: error_msg.append(u'You do not have permissions to modify ' u'this review request\n') elif e.error_code == 219: error_msg.append( u'The generated diff file was empty. This ' u'usually means no files were\n' u'modified in this change.\n') else: error_msg.append(str(e).decode('utf-8') + u'\n') error_msg.append( u'Your review request still exists, but the diff is ' u'not attached.\n') error_msg.append(u'%s\n' % review_request.absolute_url) raise CommandError(u'\n'.join(error_msg)) try: draft = review_request.get_draft(only_fields='commit_id') except APIError as e: raise CommandError('Error retrieving review request draft: %s' % e) # Update the review request draft fields based on options set # by the user, or configuration. update_fields = {} if self.options.target_groups: update_fields['target_groups'] = self.options.target_groups if self.options.target_people: update_fields['target_people'] = self.options.target_people if self.options.depends_on: update_fields['depends_on'] = self.options.depends_on if self.options.summary: update_fields['summary'] = self.options.summary if self.options.branch: update_fields['branch'] = self.options.branch if self.options.bugs_closed: # Append to the existing list of bugs. self.options.bugs_closed = self.options.bugs_closed.strip(', ') bug_set = (set(re.split('[, ]+', self.options.bugs_closed)) | set(review_request.bugs_closed)) self.options.bugs_closed = ','.join(bug_set) update_fields['bugs_closed'] = self.options.bugs_closed if self.options.description: update_fields['description'] = self.options.description if self.options.testing_done: update_fields['testing_done'] = self.options.testing_done if ((self.options.description or self.options.testing_done) and self.options.markdown and self.tool.capabilities.has_capability('text', 'markdown')): # The user specified that their Description/Testing Done are # valid Markdown, so tell the server so it won't escape the text. update_fields['text_type'] = 'markdown' if self.options.change_description: update_fields['changedescription'] = \ self.options.change_description if self.options.publish: update_fields['public'] = True if supports_posting_commit_ids and commit_id != draft.commit_id: update_fields['commit_id'] = commit_id or '' if update_fields: try: draft = draft.update(**update_fields) except APIError as e: raise CommandError(u'\n'.join([ u'Error updating review request draft: %s\n' % e, u'Your review request still exists, but the diff is ' u'not attached.\n', u'%s\n' % review_request.absolute_url, ])) return review_request.id, review_request.absolute_url def check_guess_fields(self): """Checks and handles field guesses for the review request. This will attempt to guess the values for the summary and description fields, based on the contents of the commit message at the provided revisions, if requested by the caller. If the backend doesn't support guessing, or if guessing isn't requested, or if explicit values were set in the options, nothing will be set for the fields. """ is_new_review_request = (not self.options.rid and not self.options.update) guess_summary = (self.options.guess_summary == self.GUESS_YES or (self.options.guess_summary == self.GUESS_AUTO and is_new_review_request)) guess_description = (self.options.guess_description == self.GUESS_YES or (self.options.guess_description == self.GUESS_AUTO and is_new_review_request)) if guess_summary or guess_description: try: assert self.revisions commit_message = self.tool.get_commit_message(self.revisions) if commit_message: if guess_summary: self.options.summary = commit_message['summary'] if guess_description: self.options.description = \ commit_message['description'] except NotImplementedError: # The SCMClient doesn't support getting commit messages, # so we can't provide the guessed versions. pass def _ask_review_request_match(self, review_request): question = ("Update Review Request #%s: '%s'? " % (review_request.id, get_draft_or_current_value('summary', review_request))) return confirm(question) def main(self, *args): """Create and update review requests.""" # The 'args' tuple must be made into a list for some of the # SCM Clients code. The way arguments were structured in # post-review meant this was a list, and certain parts of # the code base try and concatenate args to the end of # other lists. Until the client code is restructured and # cleaned up we will satisfy the assumption here. self.cmd_args = list(args) self.post_process_options() origcwd = os.path.abspath(os.getcwd()) repository_info, self.tool = self.initialize_scm_tool( client_name=self.options.repository_type) server_url = self.get_server_url(repository_info, self.tool) api_client, api_root = self.get_api(server_url) self.setup_tool(self.tool, api_root=api_root) if (self.options.exclude_patterns and not self.tool.supports_diff_exclude_patterns): raise CommandError( 'The %s backend does not support excluding files via the ' '-X/--exclude commandline options or the EXCLUDE_PATTERNS ' '.reviewboardrc option.' % self.tool.name) # Check if repository info on reviewboard server match local ones. repository_info = repository_info.find_server_repository_info(api_root) if self.options.diff_filename: self.revisions = None parent_diff = None base_commit_id = None commit_id = None if self.options.diff_filename == '-': if hasattr(sys.stdin, 'buffer'): # Make sure we get bytes on Python 3.x diff = sys.stdin.buffer.read() else: diff = sys.stdin.read() else: try: diff_path = os.path.join(origcwd, self.options.diff_filename) with open(diff_path, 'rb') as fp: diff = fp.read() except IOError as e: raise CommandError('Unable to open diff filename: %s' % e) else: self.revisions = get_revisions(self.tool, self.cmd_args) if self.revisions: extra_args = None else: extra_args = self.cmd_args # Generate a diff against the revisions or arguments, filtering # by the requested files if provided. diff_info = self.tool.diff( revisions=self.revisions, include_files=self.options.include_files or [], exclude_patterns=self.options.exclude_patterns or [], extra_args=extra_args) diff = diff_info['diff'] parent_diff = diff_info.get('parent_diff') base_commit_id = diff_info.get('base_commit_id') commit_id = diff_info.get('commit_id') repository = (self.options.repository_name or self.options.repository_url or self.get_repository_path(repository_info, api_root)) base_dir = self.options.basedir or repository_info.base_path if len(diff) == 0: raise CommandError("There don't seem to be any diffs!") # Validate the diffs to ensure that they can be parsed and that # all referenced files can be found. # # Review Board 2.0.14+ (with the diffs.validation.base_commit_ids # capability) is required to successfully validate against hosting # services that need a base_commit_id. This is basically due to # the limitations of a couple Git-specific hosting services # (Beanstalk, Bitbucket, and Unfuddle). # # In order to validate, we need to either not be dealing with a # base commit ID (--diff-filename), or be on a new enough version # of Review Board, or be using a non-Git repository. can_validate_base_commit_ids = \ self.tool.capabilities.has_capability('diffs', 'validation', 'base_commit_ids') if (not base_commit_id or can_validate_base_commit_ids or self.tool.name != 'Git'): # We can safely validate this diff before posting it, but we # need to ensure we only pass base_commit_id if the capability # is set. validate_kwargs = {} if can_validate_base_commit_ids: validate_kwargs['base_commit_id'] = base_commit_id try: diff_validator = api_root.get_diff_validation() diff_validator.validate_diff(repository, diff, parent_diff=parent_diff, base_dir=base_dir, **validate_kwargs) except APIError as e: msg_prefix = '' if e.error_code == 207: msg_prefix = '%s: ' % e.rsp['file'] raise CommandError('Error validating diff\n\n%s%s' % (msg_prefix, e)) except AttributeError: # The server doesn't have a diff validation resource. Post as # normal. pass if repository_info.supports_changesets and 'changenum' in diff_info: changenum = diff_info['changenum'] commit_id = changenum else: changenum = None if not self.options.diff_filename: # If the user has requested to guess the summary or description, # get the commit message and override the summary and description # options. self.check_guess_fields() if self.options.update and self.revisions: self.options.rid = guess_existing_review_request_id( repository_info, self.options.repository_name, api_root, api_client, self.tool, self.revisions, guess_summary=False, guess_description=False, is_fuzzy_match_func=self._ask_review_request_match) if not self.options.rid: raise CommandError('Could not determine the existing review ' 'request to update.') # If only certain files within a commit are being submitted for review, # do not include the commit id. This prevents conflicts if mutliple # files from the same commit are posted for review separately. if self.options.include_files: commit_id = None request_id, review_url = self.post_request( repository_info, repository, server_url, api_root, self.options.rid, changenum=changenum, diff_content=diff, parent_diff_content=parent_diff, commit_id=commit_id, base_commit_id=base_commit_id, submit_as=self.options.submit_as, base_dir=base_dir) diff_review_url = review_url + 'diff/' print('Review request #%s posted.' % request_id) print() print(review_url) print(diff_review_url) # Load the review up in the browser if requested to. if self.options.open_browser: try: import webbrowser if 'open_new_tab' in dir(webbrowser): # open_new_tab is only in python 2.5+ webbrowser.open_new_tab(review_url) elif 'open_new' in dir(webbrowser): webbrowser.open_new(review_url) else: os.system('start %s' % review_url) except: logging.error('Error opening review URL: %s' % review_url)
class Land(Command): """Land changes from a review request onto the remote repository. This command takes a review request, applies it to a feature branch, merges it with the specified destination branch, and pushes the changes to an upstream repository. Notes: The review request needs to be approved first. ``--local`` option can be used to skip the patching step. """ name = 'land' author = 'The Review Board Project' needs_api = True needs_scm_client = True needs_repository = True args = '[<branch-name>]' option_list = [ Option('--dest', dest='destination_branch', default=None, config_key='LAND_DEST_BRANCH', help='Specifies the destination branch to land changes on.'), Option('-r', '--review-request-id', dest='rid', metavar='ID', default=None, help='Specifies the review request ID.'), Option('--local', dest='is_local', action='store_true', default=None, help='Forces the change to be merged without patching, if ' 'merging a local branch. Defaults to true unless ' '--review-request-id is used.'), Option('-p', '--push', dest='push', action='store_true', default=False, config_key='LAND_PUSH', help='Pushes the branch after landing the change.'), Option('-n', '--no-push', dest='push', action='store_false', default=False, config_key='LAND_PUSH', help='Prevents pushing the branch after landing the change, ' 'if pushing is enabled by default.'), Option('--squash', dest='squash', action='store_true', default=False, config_key='LAND_SQUASH', help='Squashes history into a single commit.'), Option('--no-squash', dest='squash', action='store_false', default=False, config_key='LAND_SQUASH', help='Disables squashing history into a single commit, ' 'choosing instead to merge the branch, if squashing is ' 'enabled by default.'), Option('-e', '--edit', dest='edit', action='store_true', default=False, help='Invokes the editor to edit the commit message before ' 'landing the change.'), Option('--delete-branch', dest='delete_branch', action='store_true', config_key='LAND_DELETE_BRANCH', default=True, help="Deletes the local branch after it's landed. Only used if " "landing a local branch. This is the default."), Option('--no-delete-branch', dest='delete_branch', action='store_false', config_key='LAND_DELETE_BRANCH', default=True, help="Prevents the local branch from being deleted after it's " "landed."), Option('--dry-run', dest='dry_run', action='store_true', default=False, help='Simulates the landing of a change, without actually ' 'making any changes to the tree.'), Option('--recursive', dest='recursive', action='store_true', default=False, help='Recursively fetch patches for review requests that the ' 'specified review request depends on. This is equivalent ' 'to calling "rbt patch" for each of those review ' 'requests.', added_in='1.0'), Command.server_options, Command.repository_options, Command.branch_options, ] def patch(self, review_request_id, squash=False): """Patch a single review request's diff using rbt patch. Args: review_request_id (int): The ID of the review request to patch. squash (bool, optional): Whether to squash multiple commits into a single commit. Raises: rbtools.commands.CommandError: There was an error applying the patch. """ patch_command = [RB_MAIN, 'patch'] patch_command.extend(build_rbtools_cmd_argv(self.options)) if self.options.edit: patch_command.append('-c') else: patch_command.append('-C') if squash: patch_command.append('--squash') patch_command.append(six.text_type(review_request_id)) rc, output = execute(patch_command, ignore_errors=True, return_error_code=True) if rc: raise CommandError('Failed to execute "rbt patch":\n%s' % output) def can_land(self, review_request): """Determine if the review request is land-able. A review request can be landed if it is approved or, if the Review Board server does not keep track of approval, if the review request has a ship-it count. This function returns the error with landing the review request or None if it can be landed. """ try: is_rr_approved = review_request.approved approval_failure = review_request.approval_failure except AttributeError: # The Review Board server is an old version (pre-2.0) that # doesn't support the `approved` field. Determine it manually. if review_request.ship_it_count == 0: is_rr_approved = False approval_failure = \ 'The review request has not been marked "Ship It!"' else: is_rr_approved = True except Exception as e: logging.exception( 'Unexpected error while looking up review request ' 'approval state: %s', e) return ('An error was encountered while executing the land ' 'command.') finally: if not is_rr_approved: return approval_failure return None def land(self, destination_branch, review_request, source_branch=None, squash=False, edit=False, delete_branch=True, dry_run=False): """Land an individual review request. Args: destination_branch (unicode): The destination branch that the change will be committed or merged to. review_request (rbtools.api.resource.ReviewRequestResource): The review request containing the change to land. source_branch (unicode, optional): The source branch to land, if landing from a local branch. squash (bool, optional): Whether to squash the changes on the branch, for repositories that support it. edit (bool, optional): Whether to edit the commit message before landing. delete_branch (bool, optional): Whether to delete/close the branch, if landing from a local branch. dry_run (bool, optional): Whether to simulate landing without actually changing the repository. """ json_data = { 'review_request': review_request.id, 'destination_branch': destination_branch, } if source_branch: review_commit_message = extract_commit_message(review_request) author = review_request.get_submitter() json_data['source_branch'] = source_branch if squash: self.stdout.write('Squashing branch "%s" into "%s".' % (source_branch, destination_branch)) json_data['type'] = 'squash' else: self.stdout.write('Merging branch "%s" into "%s".' % (source_branch, destination_branch)) json_data['type'] = 'merge' if not dry_run: try: self.tool.merge(target=source_branch, destination=destination_branch, message=review_commit_message, author=author, squash=squash, run_editor=edit, close_branch=delete_branch) except MergeError as e: raise CommandError(six.text_type(e)) else: self.stdout.write('Applying patch from review request %s.' % review_request.id) if not dry_run: self.patch(review_request.id, squash=squash) self.stdout.write('Review request %s has landed on "%s".' % (review_request.id, self.options.destination_branch)) self.json.append('landed_review_requests', json_data) def initialize(self): """Initialize the command. This overrides Command.initialize in order to handle full review request URLs on the command line. In this case, we want to parse that URL in order to pull the server name and review request ID out of it. Raises: rbtools.commands.CommandError: A review request URL passed in as the review request ID could not be parsed correctly or included a bad diff revision. """ review_request_id = self.options.rid if review_request_id and review_request_id.startswith('http'): server_url, review_request_id, diff_revision = \ parse_review_request_url(review_request_id) if diff_revision and '-' in diff_revision: raise CommandError('Interdiff patches are not supported: %s.' % diff_revision) if review_request_id is None: raise CommandError('The URL %s does not appear to be a ' 'review request.') self.options.server = server_url self.options.rid = review_request_id super(Land, self).initialize() def main(self, branch_name=None, *args): """Run the command.""" self.cmd_args = list(args) if branch_name: self.cmd_args.insert(0, branch_name) if not self.tool.can_merge: raise CommandError( 'This command does not support %s repositories.' % self.tool.name) if self.options.push and not self.tool.can_push_upstream: raise CommandError('--push is not supported for %s repositories.' % self.tool.name) if self.tool.has_pending_changes(): raise CommandError('Working directory is not clean.') if not self.options.destination_branch: raise CommandError('Please specify a destination branch.') if not self.tool.can_squash_merges: # If the client doesn't support squashing, then never squash. self.options.squash = False if self.options.rid: is_local = branch_name is not None review_request_id = self.options.rid else: try: review_request = guess_existing_review_request( api_root=self.api_root, api_client=self.api_client, tool=self.tool, revisions=get_revisions(self.tool, self.cmd_args), guess_summary=False, guess_description=False, is_fuzzy_match_func=self._ask_review_request_match, repository_id=self.repository.id) except ValueError as e: raise CommandError(six.text_type(e)) if not review_request or not review_request.id: raise CommandError('Could not determine the existing review ' 'request URL to land.') review_request_id = review_request.id is_local = True try: review_request = self.api_root.get_review_request( review_request_id=review_request_id) except APIError as e: raise CommandError('Error getting review request %s: %s' % (review_request_id, e)) if self.options.is_local is not None: is_local = self.options.is_local if is_local: if branch_name is None: branch_name = self.tool.get_current_branch() if branch_name == self.options.destination_branch: raise CommandError('The local branch cannot be merged onto ' 'itself. Try a different local branch or ' 'destination branch.') else: branch_name = None land_error = self.can_land(review_request) if land_error is not None: raise CommandError('Cannot land review request %s: %s' % (review_request_id, land_error)) land_kwargs = { 'delete_branch': self.options.delete_branch, 'destination_branch': self.options.destination_branch, 'dry_run': self.options.dry_run, 'edit': self.options.edit, 'squash': self.options.squash, } self.json.add('landed_review_requests', []) if self.options.recursive: # The dependency graph shows us which review requests depend on # which other ones. What we are actually after is the order to land # them in, which is the topological sorting order of the converse # graph. It just so happens that if we reverse the topological sort # of a graph, it is a valid topological sorting of the converse # graph, so we don't have to compute the converse graph. dependency_graph = review_request.build_dependency_graph() dependencies = toposort(dependency_graph)[1:] if dependencies: self.stdout.write('Recursively landing dependencies of ' 'review request %s.' % review_request_id) for dependency in dependencies: land_error = self.can_land(dependency) if land_error is not None: raise CommandError( 'Aborting recursive land of review request %s.\n' 'Review request %s cannot be landed: %s' % (review_request_id, dependency.id, land_error)) for dependency in reversed(dependencies): self.land(review_request=dependency, **land_kwargs) self.land(review_request=review_request, source_branch=branch_name, **land_kwargs) if self.options.push: self.stdout.write('Pushing branch "%s" upstream' % self.options.destination_branch) if not self.options.dry_run: try: self.tool.push_upstream(self.options.destination_branch) except PushError as e: raise CommandError(six.text_type(e)) def _ask_review_request_match(self, review_request): return confirm('Land Review Request #%s: "%s"? ' % (review_request.id, get_draft_or_current_value('summary', review_request)))
class Stamp(Command): """Add the review request URL to the last commit message. Guesses the existing review request ID and stamp the review request URL to the last commit message. If a review request ID is specified by the user, use the specified review request URL instead of guessing. """ name = 'stamp' author = 'The Review Board Project' option_list = [ OptionGroup( name='Stamp Options', description='Controls the behavior of a stamp, including what ' 'review request URL gets stamped.', option_list=[ Option('-r', '--review-request-id', dest='rid', metavar='ID', default=None, help='Specifies the existing review request ID to ' 'be stamped.'), ]), Command.server_options, Command.repository_options, Command.diff_options, ] def no_commit_error(self): raise CommandError('No existing commit to stamp on.') def _ask_review_request_match(self, review_request): question = ("Stamp last commit with Review Request #%s: '%s'? " % (review_request.id, get_draft_or_current_value('summary', review_request))) return confirm(question) def main(self, *args): """Stamp the latest commit with corresponding review request URL""" self.cmd_args = list(args) repository_info, self.tool = self.initialize_scm_tool( client_name=self.options.repository_type) server_url = self.get_server_url(repository_info, self.tool) api_client, api_root = self.get_api(server_url) self.setup_tool(self.tool, api_root=api_root) if not self.tool.can_amend_commit: raise NotImplementedError('rbt stamp is not supported with %s.' % self.tool.name) try: if self.tool.has_pending_changes(): raise CommandError('Working directory is not clean.') except NotImplementedError: pass revisions = get_revisions(self.tool, self.cmd_args) commit_message = self.tool.get_raw_commit_message(revisions) if '\nReviewed at http' in commit_message: raise CommandError('This commit is already stamped.') if not self.options.rid: self.options.rid = guess_existing_review_request_id( repository_info, self.options.repository_name, api_root, api_client, self.tool, revisions, guess_summary=False, guess_description=False, is_fuzzy_match_func=self._ask_review_request_match, no_commit_error=self.no_commit_error) if not self.options.rid: raise CommandError('Could not determine the existing review ' 'request URL to stamp with.') review_request = get_review_request(self.options.rid, api_root) stamp_url = review_request.absolute_url commit_message += '\n\nReviewed at %s' % stamp_url self.tool.amend_commit(commit_message) print('Changes committed to current branch.')
class Patch(Command): """Applies a specific patch from a RB server. The patch file indicated by the request id is downloaded from the server and then applied locally.""" name = 'patch' author = 'The Review Board Project' args = '<review-request-id>' option_list = [ Option('-c', '--commit', dest='commit', action='store_true', default=False, help='Commits using information fetched ' 'from the review request (Git/Mercurial only).', added_in='0.5.3'), Option('-C', '--commit-no-edit', dest='commit_no_edit', action='store_true', default=False, help='Commits using information fetched ' 'from the review request (Git/Mercurial only). ' 'This differs from --commit by not invoking the editor ' 'to modify the commit message.'), Option('--diff-revision', dest='diff_revision', metavar='REVISION', default=None, help='The Review Board diff revision ID to use for the patch.'), Option('--px', dest='px', metavar='NUM', default=None, help="Strips the given number of paths from filenames in the " "diff. Equivalent to patch's `-p` argument."), Option('--print', dest='patch_stdout', action='store_true', default=False, help='Prints the patch to standard output instead of applying ' 'it to the tree.', added_in='0.5.3'), Option('-R', '--revert', dest='revert_patch', action='store_true', default=False, help='Revert the given patch instead of applying it.\n' 'This feature does not work with Bazaar or Mercurial ' 'repositories.', added_in='0.7.3'), Option('--commit-ids', dest='commit_ids', default=None, help='Comma-separated list of commit IDs to apply.\n' 'This only applies to review requests created with commit ' 'history.', added_in='2.0'), Option('--squash', dest='squash', action='store_true', default=False, help='Squash all patches into one commit. This is only used if ' 'also using -c/--commit or -C/--commit-no-edit.', added_in='2.0'), Command.server_options, Command.repository_options, ] def get_patches(self, diff_revision=None, commit_ids=None, squashed=False, reverted=False): """Return the requested patches and their metadata. If a diff revision is not specified, then this will look at the most recent diff. Args: diff_revision (int, optional): The diff revision to apply. The latest revision will be used if not provided. commit_ids (list of unicode, optional): The specific commit IDs to apply. If not specified, the squashed version of any commits (or the sole diff, in a non-multi-commit review request) will be applied. squashed (bool, optional): Whether to return a squashed version of the commits, if using a multi-commit review request. reverted (bool, optional): Return patches in the order needed to revert them. Returns: list of dict: A list of dictionaries with the following keys: ``basedir`` (:py:class:`unicode`): The base directory of the returned patch. ``diff`` (:py:class:`bytes`): The actual patch contents. ``patch_num`` (:py:class:`int`): The application number for the patch. This is 1-based. ``revision`` (:py:class:`int`): The revision of the returned patch. ``commit_meta`` (:py:class:`dict`): Metadata about the requested commit if one was requested. Otherwise, this will be ``None``. Raises: rbtools.command.CommandError: One of the following occurred: * The patch could not be retrieved or does not exist * The review request was created without history support and ``commit_ids`` was provided. * One or more requested commit IDs could not be found. """ if commit_ids is not None: commit_ids = set(commit_ids) # Sanity-check the arguments, making sure that the options provided # are compatible with each other and with the Review Board server. server_supports_history = self._tool.capabilities.has_capability( 'review_requests', 'supports_history') if server_supports_history: if squashed and commit_ids: logger.warning('--squash is not compatible with --commit-ids; ' 'ignoring --squash') squashed = False else: squashed = True if commit_ids: logger.warning('This server does not support review requests ' 'with history; ignoring --commit-ids=...') commit_ids = None # If a diff revision is not specified, we'll need to get the latest # revision through the API. if diff_revision is None: try: diffs = self._api_root.get_diffs( review_request_id=self._review_request_id, only_fields='', only_links='') except APIError as e: raise CommandError('Error getting diffs: %s' % e) # Use the latest diff if a diff revision was not given. # Since diff revisions start a 1, increment by one, and # never skip a number, the latest diff revisions number # should be equal to the number of diffs. diff_revision = diffs.total_results try: # Fetch the main diff and (unless we're squashing) any commits within. if squashed: diff = self._api_root.get_diff( review_request_id=self._review_request_id, diff_revision=diff_revision) else: diff = self._api_root.get_diff( review_request_id=self._review_request_id, diff_revision=diff_revision, expand='commits') except APIError: raise CommandError('The specified diff revision does not ' 'exist.') # Begin to gather results. patches = [] if squashed or len(diff.commits) == 0: # Either this was a review request created before we had # multi-commit, or the user requested to squash everything. Return # a single patch. try: diff_content = diff.get_patch().data except APIError: raise CommandError( _('Unable to retrieve the diff content for revision %s') % diff_revision) # We only have one patch to apply, containing a squashed version # of all commits. patches.append({ 'base_dir': getattr(diff, 'basedir', ''), 'commit_meta': None, 'diff': diff_content, 'patch_num': 1, 'revision': diff_revision, }) else: # We'll be returning one patch per commit. This may be the # entire list of the review request, or a filtered list. commits = diff.commits if commit_ids: # Filter the commits down by the specified list of IDs. commit_ids = set(commit_ids) commits = [ commit for commit in commits if commit['commit_id'] in commit_ids ] # Make sure we're not missing any. if len(commits) != len(commit_ids): found_commit_ids = set(commit['commit_id'] for commit in commits) raise CommandError( _('The following commit IDs could not be found: %s') % ', '.join(sorted(commit_ids - found_commit_ids))) for patch_num, commit in enumerate(commits, start=1): try: diff_content = commit.get_patch().data except APIError: raise CommandError( _('Unable to retrieve the diff content for ' 'revision %(diff_revision)d, commit %(commit_id)s') % { 'diff_revision': diff_revision, 'commit_id': commit['commit_id'], }) patches.append({ # DiffSets on review requests created with history # support *always* have an empty base dir. 'base_dir': '', 'commit_meta': { 'author': PatchAuthor(full_name=commit.author_name, email=commit.author_email), 'author_date': commit.author_date, 'committer_date': commit.committer_date, 'committer_email': commit.committer_email, 'committer_name': commit.committer_name, 'message': commit.commit_message, }, 'diff': diff_content, 'patch_num': patch_num, 'revision': diff_revision, }) if reverted: patches = list(reversed(patches)) return patches def apply_patch(self, diff_file_path, base_dir, patch_num, total_patches, revert=False): """Apply a patch to the tree. Args: diff_file_path (unicode): The file path of the diff being applied. base_dir (unicode): The base directory within which to apply the patch. patch_num (int): The 1-based index of the patch being applied. total_patches (int): The total number of patches being applied. revert (bool, optional): Whether the patch is being reverted. Returns: bool: ``True`` if the patch was applied/reverted successfully. ``False`` if the patch was partially applied/reverted but there were conflicts. Raises: rbtools.command.CommandError: There was an error applying or reverting the patch. """ # If we're working with more than one patch, show the patch number # we're applying or reverting. If we're only working with one, the # previous log from _apply_patches() will suffice. if total_patches > 1: if revert: msg = _('Reverting patch %(num)d/%(total)d...') else: msg = _('Applying patch %(num)d/%(total)d...') logger.info(msg, { 'num': patch_num, 'total': total_patches, }) result = self._tool.apply_patch( patch_file=diff_file_path, base_path=self._repository_info.base_path, base_dir=base_dir, p=self.options.px, revert=revert) if result.patch_output: print() patch_output = result.patch_output.strip() if six.PY2: print(patch_output) else: sys.stdout.buffer.write(patch_output) print() print() if not result.applied: if revert: raise CommandError( 'Unable to revert the patch. The patch may be invalid, or ' 'there may be conflicts that could not be resolved.') else: raise CommandError( 'Unable to apply the patch. The patch may be invalid, or ' 'there may be conflicts that could not be resolved.') if result.has_conflicts: if result.conflicting_files: if revert: print('The patch was partially reverted, but there were ' 'conflicts in:') else: print('The patch was partially applied, but there were ' 'conflicts in:') print() for filename in result.conflicting_files: print(' %s' % filename) print() elif revert: print('The patch was partially reverted, but there were ' 'conflicts.') else: print('The patch was partially applied, but there were ' 'conflicts.') return False return True def main(self, review_request_id): """Run the command. Args: review_request_id (int): The ID of the review request to patch from. Raises: rbtools.command.CommandError: Patching the tree has failed. """ patch_stdout = self.options.patch_stdout revert = self.options.revert_patch if patch_stdout and revert: raise CommandError(_('--print and --revert cannot both be used.')) repository_info, tool = self.initialize_scm_tool( client_name=self.options.repository_type, require_repository_info=not patch_stdout) server_url = self.get_server_url(repository_info, tool) diff_revision = None if review_request_id.startswith('http'): (server_url, review_request_id, diff_revision) = parse_review_request_url(review_request_id) if diff_revision and '-' in diff_revision: raise CommandError('Interdiff patches not supported: %s.' % diff_revision) if diff_revision is None: diff_revision = self.options.diff_revision if revert and not tool.supports_patch_revert: raise CommandError( _('The %s backend does not support reverting patches.') % tool.name) api_client, api_root = self.get_api(server_url) self.setup_tool(tool, api_root=api_root) if not patch_stdout: # Check if the repository info on the Review Board server matches # the local checkout. repository_info = repository_info.find_server_repository_info( api_root) # Check if the working directory is clean. try: if tool.has_pending_changes(): message = 'Working directory is not clean.' if self.options.commit: raise CommandError(message) else: logger.warning(message) except NotImplementedError: pass # Store the instances we've set up so that other commands have access. self._api_root = api_root self._repository_info = repository_info self._tool = tool self._review_request_id = review_request_id if self.options.commit_ids: # Do our best to normalize what gets passed in, so that we don't # end up with any blank entries. commit_ids = [ commit_id for commit_id in COMMIT_ID_SPLIT_RE.split( self.options.commit_ids.trim()) if commit_id ] else: commit_ids = None # Fetch the patches from the review request, based on the requested # options. patches = self.get_patches(diff_revision=diff_revision, commit_ids=commit_ids, squashed=self.options.squash, reverted=revert) if patch_stdout: self._output_patches(patches) else: self._apply_patches(patches) def _output_patches(self, patches): """Output the contents of the patches to the console. Args: patches (list of dict): The list of patches that would be applied. """ for patch_data in patches: diff_body = patch_data['diff'] if isinstance(diff_body, bytes): if six.PY3: sys.stdout.buffer.write(diff_body) print() else: print(diff_body.decode('utf-8')) else: print(diff_body) def _apply_patches(self, patches): """Apply a list of patches to the tree. Args: patches (list of dict): The list of patches to apply. Raises: rbtools.command.CommandError: Patching the tree has failed. """ squash = self.options.squash revert = self.options.revert_patch commit_no_edit = self.options.commit_no_edit will_commit = self.options.commit or commit_no_edit total_patches = len(patches) # Check if we're planning to commit and have any patch without # metadata, in which case we'll need to fetch the review request so we # can generate a commit message. needs_review_request = will_commit and ( squash or total_patches == 1 or any(patch_data['commit_meta'] is None for patch_data in patches)) if needs_review_request: # Fetch the review request to use as a description. We only # want to fetch this once. try: review_request = self._api_root.get_review_request( review_request_id=self._review_request_id, force_text_type='plain') except APIError as e: raise CommandError( _('Error getting review request %(review_request_id)d: ' '%(error)s') % { 'review_request_id': self._review_request_id, 'error': e, }) default_author = review_request.get_submitter() default_commit_message = extract_commit_message(review_request) else: default_author = None default_commit_message = None # Display a summary of what's about to be applied. diff_revision = patches[0]['revision'] if revert: summary = ngettext( ('Reverting 1 patch from review request ' '%(review_request_id)s (diff revision %(diff_revision)s)'), ('Reverting %(num)d patches from review request ' '%(review_request_id)s (diff revision %(diff_revision)s)'), total_patches) else: summary = ngettext( ('Applying 1 patch from review request ' '%(review_request_id)s (diff revision %(diff_revision)s)'), ('Applying %(num)d patches from review request ' '%(review_request_id)s (diff revision %(diff_revision)s)'), total_patches) logger.info( summary, { 'num': total_patches, 'review_request_id': self._review_request_id, 'diff_revision': diff_revision, }) # Start applying all the patches. for patch_data in patches: patch_num = patch_data['patch_num'] tmp_patch_file = make_tempfile(patch_data['diff']) success = self.apply_patch(diff_file_path=tmp_patch_file, base_dir=patch_data['base_dir'], patch_num=patch_num, total_patches=total_patches, revert=revert) os.unlink(tmp_patch_file) if not success: if revert: error = _('Could not apply patch %(num)d of %(total)d') else: error = _('Could not revert patch %(num)d of %(total)d') raise CommandError(error % { 'num': patch_num, 'total': total_patches, }) # If the user wants to commit, then we'll be committing every # patch individually, unless the user wants to squash commits in # which case we'll only do this on the final commit. if will_commit and (not squash or patch_num == total_patches): meta = patch_data.get('commit_meta') if meta is not None and not squash and total_patches > 1: # We are patching a commit so we already have the metadata # required without making additional HTTP requests. message = meta['message'] author = meta['author'] else: # We'll build this based on the summary/description from # the review request and the patch number. message = default_commit_message author = default_author assert message is not None assert author is not None if total_patches > 1: # Record the patch number to help differentiate, in # case we only have review request information and # not commit messages. In practice, this shouldn't # happen, as we should always have commit messages, # but it's a decent safeguard. message = '[%s/%s] %s' % (patch_num, total_patches, message) if revert: # Make it clear that this commit is reverting a prior # patch, so it's easy to identify. message = '[Revert] %s' % message try: self._tool.create_commit(message=message, author=author, run_editor=not commit_no_edit) except CreateCommitError as e: raise CommandError(six.text_type(e)) except NotImplementedError: raise CommandError('--commit is not supported with %s' % self._tool.name)
class Patch(Command): """Applies a specific patch from a RB server. The patch file indicated by the request id is downloaded from the server and then applied locally.""" name = "patch" author = "The Review Board Project" args = "<review-request-id>" option_list = [ Option("--diff-revision", dest="diff_revision", default=None, help="revision id of diff to be used as patch"), Option("--px", dest="px", default=None, help="numerical pX argument for patch"), Option("--print", dest="patch_stdout", action="store_true", default=False, help="print patch to stdout instead of applying"), Option("--server", dest="server", metavar="SERVER", config_key="REVIEWBOARD_URL", default=None, help="specify a different Review Board server to use"), Option("--username", dest="username", metavar="USERNAME", config_key="USERNAME", default=None, help="user name to be supplied to the Review Board server"), Option("--password", dest="password", metavar="PASSWORD", config_key="PASSWORD", default=None, help="password to be supplied to the Review Board server"), Option('--repository-type', dest='repository_type', config_key="REPOSITORY_TYPE", default=None, help='the type of repository in the current directory. ' 'In most cases this should be detected ' 'automatically but some directory structures ' 'containing multiple repositories require this ' 'option to select the proper type. Valid ' 'values include bazaar, clearcase, cvs, git, ' 'mercurial, perforce, plastic, and svn.'), ] def get_patch(self, request_id, api_root, diff_revision=None): """Return the diff as a string, the used diff revision and its basedir. If a diff revision is not specified, then this will look at the most recent diff. """ try: diffs = api_root.get_diffs(review_request_id=request_id) except APIError, e: raise CommandError("Error getting diffs: %s" % e) # Use the latest diff if a diff revision was not given. # Since diff revisions start a 1, increment by one, and # never skip a number, the latest diff revisions number # should be equal to the number of diffs. if diff_revision is None: diff_revision = diffs.total_results try: diff = diffs.get_item(diff_revision) diff_body = diff.get_patch().data base_dir = diff.basedir except APIError: raise CommandError('The specified diff revision does not exist.') return diff_body, diff_revision, base_dir
class Post(Command): """Create and update review requests.""" name = "post" author = "The Review Board Project" args = "[changenum]" option_list = [ Option("-r", "--review-request-id", dest="rid", metavar="ID", default=None, help="existing review request ID to update"), Option("--server", dest="server", metavar="SERVER", config_key="REVIEWBOARD_URL", default=None, help="specify a different Review Board server to use"), Option("--disable-proxy", action='store_false', dest='enable_proxy', config_key="ENABLE_PROXY", default=True, help="prevents requests from going through a proxy server"), Option('-p', '--publish', dest="publish", action="store_true", default=False, help="publish the review request immediately after submitting"), Option("--target-groups", dest="target_groups", config_key="TARGET_GROUPS", default=None, help="names of the groups who will perform the review"), Option("--target-people", dest="target_people", config_key="TARGET_PEOPLE", default=None, help="names of the people who will perform the review"), Option("--summary", dest="summary", default=None, help="summary of the review "), Option("--description", dest="description", default=None, help="description of the review "), Option("--description-file", dest="description_file", default=None, help="text file containing a description of the review"), Option('-g', '--guess-fields', dest="guess_fields", action="store_true", config_key="GUESS_FIELDS", default=False, help="equivalent to --guess-summary --guess-description"), Option("--guess-summary", dest="guess_summary", action="store_true", config_key="GUESS_SUMMARY", default=False, help="guess summary from the latest commit " "(bzr/git/hg/hgsubversion only)"), Option("--guess-description", dest="guess_description", action="store_true", config_key="GUESS_DESCRIPTION", default=False, help="guess description based on commits on this branch " "(bzr/git/hg/hgsubversion only)"), Option("--testing-done", dest="testing_done", default=None, help="details of testing done "), Option("--testing-done-file", dest="testing_file", default=None, help="text file containing details of testing done "), Option("--branch", dest="branch", config_key="BRANCH", default=None, help="affected branch "), Option("--bugs-closed", dest="bugs_closed", default=None, help="list of bugs closed "), Option("--change-description", default=None, help="description of what changed in this revision of " "the review request when updating an existing request"), Option("--revision-range", dest="revision_range", default=None, help="generate the diff for review based on given " "revision range"), Option("--submit-as", dest="submit_as", metavar="USERNAME", config_key="SUBMIT_AS", default=None, help="user name to be recorded as the author of the " "review request, instead of the logged in user"), Option("--username", dest="username", metavar="USERNAME", config_key="USERNAME", default=None, help="user name to be supplied to the Review Board server"), Option("--password", dest="password", metavar="PASSWORD", config_key="PASSWORD", default=None, help="password to be supplied to the Review Board server"), Option("--change-only", dest="change_only", action="store_true", default=False, help="updates info from changelist, but does " "not upload a new diff (only available if your " "repository supports changesets)"), Option("--parent", dest="parent_branch", metavar="PARENT_BRANCH", config_key="PARENT_BRANCH", default=None, help="the parent branch this diff should be against " "(only available if your repository supports " "parent diffs)"), Option("--tracking-branch", dest="tracking", metavar="TRACKING", config_key="TRACKING_BRANCH", default=None, help="Tracking branch from which your branch is derived " "(git only, defaults to origin/master)"), Option("--p4-client", dest="p4_client", config_key="P4_CLIENT", default=None, help="the Perforce client name that the review is in"), Option("--p4-port", dest="p4_port", config_key="P4_PORT", default=None, help="the Perforce servers IP address that the review is on"), Option("--p4-passwd", dest="p4_passwd", config_key="P4_PASSWD", default=None, help="the Perforce password or ticket of the user " "in the P4USER environment variable"), Option("--svn-changelist", dest="svn_changelist", default=None, help="generate the diff for review based on a local SVN " "changelist"), Option("--repository-url", dest="repository_url", config_key="REPOSITORY", default=None, help="the url for a repository for creating a diff " "outside of a working copy (currently only " "supported by Subversion with --revision-range or " "--diff-filename and ClearCase with relative " "paths outside the view). For git, this specifies" "the origin url of the current repository, " "overriding the origin url supplied by the git " "client."), Option("-d", "--debug", action="store_true", dest="debug", config_key="DEBUG", default=False, help="display debug output"), Option("--diff-filename", dest="diff_filename", default=None, help="upload an existing diff file, instead of " "generating a new diff"), Option("--http-username", dest="http_username", metavar="USERNAME", config_key="HTTP_USERNAME", default=None, help="username for HTTP Basic authentication"), Option("--http-password", dest="http_password", metavar="PASSWORD", config_key="HTTP_PASSWORD", default=None, help="password for HTTP Basic authentication"), Option("--basedir", dest="basedir", default=None, help="the absolute path in the repository the diff was " "generated in. Will override the detected path."), ] def post_process_options(self): if self.options.debug: logging.getLogger().setLevel(logging.DEBUG) if self.options.description and self.options.description_file: sys.stderr.write("The --description and --description-file " "options are mutually exclusive.\n") sys.exit(1) if self.options.description_file: if os.path.exists(self.options.description_file): fp = open(self.options.description_file, "r") self.options.description = fp.read() fp.close() else: sys.stderr.write("The description file %s does not exist.\n" % self.options.description_file) sys.exit(1) if self.options.guess_fields: self.options.guess_summary = True self.options.guess_description = True if self.options.testing_done and self.options.testing_file: sys.stderr.write("The --testing-done and --testing-done-file " "options are mutually exclusive.\n") sys.exit(1) if self.options.testing_file: if os.path.exists(self.options.testing_file): fp = open(self.options.testing_file, "r") self.options.testing_done = fp.read() fp.close() else: sys.stderr.write("The testing file %s does not exist.\n" % self.options.testing_file) sys.exit(1) def get_repository_path(self, repository_info, api_root): """Get the repository path from the server. This will compare the paths returned by the SCM client with those one the server, and return the first match. """ if isinstance(repository_info.path, list): repositories = api_root.get_repositories() try: while True: for repo in repositories: if repo['path'] in repository_info.path: repository_info.path = repo['path'] raise StopIteration() repositories = repositories.get_next() except StopIteration: pass if isinstance(repository_info.path, list): sys.stderr.write('\n') sys.stderr.write('There was an error creating this review ' 'request.\n') sys.stderr.write('\n') sys.stderr.write('There was no matching repository path' 'found on the server.\n') sys.stderr.write('Unknown repository paths found:\n') for foundpath in repository_info.path: sys.stderr.write('\t%s\n' % foundpath) sys.stderr.write('Ask the administrator to add one of ' 'these repositories\n') sys.stderr.write('to the Review Board server.\n') die() return repository_info.path def post_request(self, tool, repository_info, server_url, api_root, changenum=None, diff_content=None, parent_diff_content=None, submit_as=None, retries=3): """Creates or updates a review request, and uploads a diff. On success the review request id and url are returned. """ if self.options.rid: # Retrieve the review request coresponding to the user # provided id. try: review_request = api_root.get_review_request( review_request_id=self.options.rid) except APIError, e: die("Error getting review request %s: %s" % (self.options.rid, e)) if review_request.status == 'submitted': die("Review request %s is marked as %s. In order to " "update it, please reopen the request and try again." % (self.options.rid, review_request.status)) else:
class Publish(Command): """Publish a specific review request from a draft.""" name = 'publish' author = 'The Review Board Project' args = '<review-request-id>' option_list = [ Command.server_options, Command.repository_options, Option('-t', '--trivial', dest='trivial_publish', action='store_true', default=False, help='Publish the review request without sending an e-mail ' 'notification.', added_in='1.0'), Option('--markdown', dest='markdown', action='store_true', config_key='MARKDOWN', default=False, help='Specifies if the change description should should be ' 'interpreted as Markdown-formatted text.', added_in='1.0'), Option('-m', '--change-description', dest='change_description', default=None, help='The change description to use for the publish.', added_in='1.0'), ] def main(self, review_request_id): """Run the command.""" repository_info, tool = self.initialize_scm_tool( client_name=self.options.repository_type) server_url = self.get_server_url(repository_info, tool) api_client, api_root = self.get_api(server_url) try: review_request = api_root.get_review_request( review_request_id=review_request_id, only_fields='public', only_links='draft') except APIError as e: raise CommandError('Error getting review request %s: %s' % (review_request_id, e)) self.setup_tool(tool, api_root) update_fields = { 'public': True, } if (self.options.trivial_publish and tool.capabilities.has_capability( 'review_requests', 'trivial_publish')): update_fields['trivial'] = True if self.options.change_description is not None: if review_request.public: update_fields['changedescription'] = \ self.options.change_description if (self.options.markdown and tool.capabilities.has_capability( 'text', 'markdown')): update_fields['changedescription_text_type'] = 'markdown' else: update_fields['changedescription_text_type'] = 'plain' else: logging.error( 'The change description field can only be set when ' 'publishing an update.') try: draft = review_request.get_draft(only_fields='') draft.update(**update_fields) except APIError as e: raise CommandError('Error publishing review request (it may ' 'already be published): %s' % e) print('Review request #%s is published.' % review_request_id)
import subprocess import sys from optparse import OptionParser try: from cStringIO import StringIO except ImportError: from StringIO import StringIO from rbtools import get_version_string from rbtools.commands import Option, RB_MAIN GLOBAL_OPTIONS = [ Option('-h', '--help', action='store_true', dest='help', default=False).make_option({}) ] def build_help_text(command_class): """Generate help text from a command class.""" command = command_class() parser = command.create_parser({}) help_file = StringIO() parser.print_help(help_file) help_text = help_file.getvalue() help_file.close() return help_text
class Status(Command): """Display review requests for the current repository.""" name = "status" author = "The Review Board Project" description = "Output a list of your pending review requests." args = "" option_list = [ Option("--all", dest="all_repositories", action="store_true", default=False, help="Show review requests for all repositories instead " "of the detected repository."), Command.server_options, Command.repository_options, Command.perforce_options, ] def output_request(self, request): print " r/%s - %s" % (request.id, request.summary) def output_draft(self, request, draft): print " * r/%s - %s" % (request.id, draft.summary) def main(self): repository_info, tool = self.initialize_scm_tool( client_name=self.options.repository_type) server_url = self.get_server_url(repository_info, tool) api_client, api_root = self.get_api(server_url) self.setup_tool(tool, api_root=api_root) user = get_user(api_client, api_root, auth_required=True) query_args = { 'from_user': user.username, 'status': 'pending', 'expand': 'draft', } if not self.options.all_repositories: repo_id = get_repository_id(repository_info, api_root, repository_name=self.config.get( 'REPOSITORY', None)) if repo_id: query_args['repository'] = repo_id else: logging.warning('The repository detected in the current ' 'directory was not found on\n' 'the Review Board server. Displaying review ' 'requests from all repositories.') requests = api_root.get_review_requests(**query_args) try: while True: for request in requests: if request.draft: self.output_draft(request, request.draft[0]) else: self.output_request(request) requests = requests.get_next(**query_args) except StopIteration: pass
class Patch(Command): """Applies a specific patch from a RB server. The patch file indicated by the request id is downloaded from the server and then applied locally.""" name = "patch" author = "The Review Board Project" args = "<review-request-id>" option_list = [ Option("-c", "--commit", dest="commit", action="store_true", default=False, help="Commit using information fetched " "from the review request (Git/Mercurial only)."), Option("-C", "--commit-no-edit", dest="commit_no_edit", action="store_true", default=False, help="Commit using information fetched " "from the review request (Git/Mercurial only). " "This differs from -c by not invoking the editor " "to modify the commit message."), Option("--diff-revision", dest="diff_revision", default=None, help="revision id of diff to be used as patch"), Option("--px", dest="px", default=None, help="numerical pX argument for patch"), Option("--print", dest="patch_stdout", action="store_true", default=False, help="print patch to stdout instead of applying"), Command.server_options, Command.repository_options, ] def get_patch(self, request_id, api_root, diff_revision=None): """Return the diff as a string, the used diff revision and its basedir. If a diff revision is not specified, then this will look at the most recent diff. """ try: diffs = api_root.get_diffs(review_request_id=request_id) except APIError, e: raise CommandError("Error getting diffs: %s" % e) # Use the latest diff if a diff revision was not given. # Since diff revisions start a 1, increment by one, and # never skip a number, the latest diff revisions number # should be equal to the number of diffs. if diff_revision is None: diff_revision = diffs.total_results try: diff = diffs.get_item(diff_revision) diff_body = diff.get_patch().data base_dir = getattr(diff, 'basedir', None) or '' except APIError: raise CommandError('The specified diff revision does not exist.') return diff_body, diff_revision, base_dir
class Stamp(Command): """Add the review request URL to the commit message. Stamps the review request URL onto the commit message of the revision specified. The revisions argument behaves like it does in rbt post, where it is required for some SCMs (e.g. Perforce) and unnecessary/ignored for others (e.g. Git). Normally, this command will guess the review request (based on the revision number if provided, and the commit summary and description otherwise). However, if a review request ID is specified by the user, it stamps the URL of that review request instead of guessing. """ name = 'stamp' author = 'The Review Board Project' description = 'Adds the review request URL to the commit message.' args = '[revisions]' option_list = [ OptionGroup( name='Stamp Options', description='Controls the behavior of a stamp, including what ' 'review request URL gets stamped.', option_list=[ Option('-r', '--review-request-id', dest='rid', metavar='ID', default=None, help='Specifies the existing review request ID to ' 'be stamped.'), ] ), Command.server_options, Command.repository_options, Command.diff_options, Command.branch_options, Command.perforce_options, ] def no_commit_error(self): raise CommandError('No existing commit to stamp on.') def _ask_review_request_match(self, review_request): question = ("Stamp with Review Request #%s: '%s'? " % (review_request.id, get_draft_or_current_value( 'summary', review_request))) return confirm(question) def determine_review_request(self, api_client, api_root, repository_info, repository_name, revisions): """Determine the correct review request for a commit. A tuple (review request ID, review request absolute URL) is returned. If no review request ID is found by any of the strategies, (None, None) is returned. """ # First, try to match the changeset to a review request directly. if repository_info.supports_changesets: review_request = find_review_request_by_change_id( api_client, api_root, repository_info, repository_name, revisions) if review_request and review_request.id: return review_request.id, review_request.absolute_url # Fall back on guessing based on the description. This may return None # if no suitable review request is found. logging.debug('Attempting to guess review request based on ' 'summary and description') review_request = guess_existing_review_request( repository_info, repository_name, api_root, api_client, self.tool, revisions, guess_summary=False, guess_description=False, is_fuzzy_match_func=self._ask_review_request_match, no_commit_error=self.no_commit_error) if review_request: logging.debug('Found review request ID %d' % review_request.id) return review_request.id, review_request.absolute_url else: logging.debug('Could not find a matching review request') return None, None def main(self, *args): """Add the review request URL to a commit message.""" self.cmd_args = list(args) repository_info, self.tool = self.initialize_scm_tool( client_name=self.options.repository_type) server_url = self.get_server_url(repository_info, self.tool) api_client, api_root = self.get_api(server_url) self.setup_tool(self.tool, api_root=api_root) if not self.tool.can_amend_commit: raise NotImplementedError('rbt stamp is not supported with %s.' % self.tool.name) try: if self.tool.has_pending_changes(): raise CommandError('Working directory is not clean.') except NotImplementedError: pass revisions = get_revisions(self.tool, self.cmd_args) # Use the ID from the command line options if present. if self.options.rid: review_request = get_review_request(self.options.rid, api_root) review_request_id = self.options.rid review_request_url = review_request.absolute_url else: review_request_id, review_request_url = \ self. determine_review_request( api_client, api_root, repository_info, self.options.repository_name, revisions) if not review_request_url: raise CommandError('Could not determine the existing review ' 'request URL to stamp with.') stamp_commit_with_review_url(revisions, review_request_url, self.tool) print('Successfully stamped change with the URL:') print(review_request_url)
class Status(Command): """Display review requests for the current repository.""" name = 'status' author = 'The Review Board Project' description = 'Output a list of your pending review requests.' args = '[review-request [revision]]' option_list = [ Option('--format', dest='format', default=None, help='Set the output format. The format is in the form of ' '%%(field_name)s, where field_name is one of: id, status,' 'summary, or description.\n' 'A character escape can be included via \\xXX where XX is ' 'the hex code of a character.\n' 'For example: --format="%%(id)s\\x09%%(summary)s"\n' 'This option will print out the id and summary tab-' 'separated.'), Option('-z', dest='format_nul', default=False, action='store_true', help='Null-terminate each entry. Otherwise, the entries will ' 'be newline-terminated.'), Option('--all', dest='all_repositories', action='store_true', default=False, help='Shows review requests for all repositories instead ' 'of just the detected repository.'), Command.server_options, Command.repository_options, Command.perforce_options, Command.tfs_options, ] # The number of spaces between the request's status and the request's id # and summary. TAB_SIZE = 3 # The number of spaces after the end of the request's summary. PADDING = 5 _HEX_RE = re.compile(r'\\x([0-9a-fA-f]{2})') def tabulate(self, review_requests): """Print review request summary and status in a table. Args: review_requests (list of dict): A list that contains statistics about each review request. """ if len(review_requests): has_branches = False has_bookmarks = False table = tt.Texttable(get_terminal_size().columns) header = ['Status', 'Review Request'] for info in review_requests: if 'branch' in info: has_branches = True if 'bookmark' in info: has_bookmarks = True if has_branches: header.append('Branch') if has_bookmarks: header.append('Bookmark') table.header(header) for info in review_requests: row = [ info['status'], 'r/%s - %s' % (info['id'], info['summary']), ] if has_branches: row.append(info.get('branch') or '') if has_bookmarks: row.append(info.get('bookmark') or '') table.add_row(row) print(table.draw()) else: print('No review requests found.') print() def get_data(self, requests): """Return current status and review summary for all reviews. Args: requests (ListResource): A ListResource that contains data on all open/draft requests. Returns: list: A list whose elements are dicts of each request's statistics. """ requests_stats = [] for request in requests.all_items: if request.draft: status = 'Draft' elif request.issue_open_count: status = 'Open Issues (%s)' % request.issue_open_count elif request.ship_it_count: status = 'Ship It! (%s)' % request.ship_it_count else: status = 'Pending' info = { 'id': request.id, 'status': status, 'summary': request.summary, 'description': request.description, } if 'local_branch' in request.extra_data: info['branch'] = \ request.extra_data['local_branch'] elif 'local_bookmark' in request.extra_data: info['bookmark'] = \ request.extra_data['local_bookmark'] requests_stats.append(info) return requests_stats def main(self): repository_info, tool = self.initialize_scm_tool( client_name=self.options.repository_type) server_url = self.get_server_url(repository_info, tool) api_client, api_root = self.get_api(server_url) self.setup_tool(tool, api_root=api_root) username = get_username(api_client, api_root, auth_required=True) # Check if repository info on reviewboard server match local ones. repository_info = repository_info.find_server_repository_info(api_root) query_args = { 'from_user': username, 'status': 'pending', 'expand': 'draft', } if not self.options.all_repositories: repo_id = get_repository_id( repository_info, api_root, repository_name=self.options.repository_name) if repo_id: query_args['repository'] = repo_id else: logging.warning('The repository detected in the current ' 'directory was not found on\n' 'the Review Board server. Displaying review ' 'requests from all repositories.') review_requests = api_root.get_review_requests(**query_args) review_request_info = self.get_data(review_requests) if self.options.format: self.format_results(review_request_info) else: self.tabulate(review_request_info) def format_results(self, review_requests): """Print formatted information about the review requests. Args: review_requests (list of dict): The information about the review requests. """ fmt = self._HEX_RE.sub( lambda m: chr(int(m.group(1), 16)), self.options.format, ) if self.options.format_nul: end = '\x00' else: end = '\n' for info in review_requests: print(fmt % info, end=end)
class Status(Command): """Display review requests for the current repository.""" name = 'status' author = 'The Review Board Project' description = 'Output a list of your pending review requests.' args = '' option_list = [ Option('--all', dest='all_repositories', action='store_true', default=False, help='Shows review requests for all repositories instead ' 'of just the detected repository.'), Command.server_options, Command.repository_options, Command.perforce_options, Command.tfs_options, ] def output_request(self, request): print(' r/%s - %s' % (request.id, request.summary)) def output_draft(self, request, draft): print(' * r/%s - %s' % (request.id, draft.summary)) def main(self): repository_info, tool = self.initialize_scm_tool( client_name=self.options.repository_type) server_url = self.get_server_url(repository_info, tool) api_client, api_root = self.get_api(server_url) self.setup_tool(tool, api_root=api_root) username = get_username(api_client, api_root, auth_required=True) # Check if repository info on reviewboard server match local ones. repository_info = repository_info.find_server_repository_info(api_root) query_args = { 'from_user': username, 'status': 'pending', 'expand': 'draft', } if not self.options.all_repositories: repo_id = get_repository_id( repository_info, api_root, repository_name=self.options.repository_name) if repo_id: query_args['repository'] = repo_id else: logging.warning('The repository detected in the current ' 'directory was not found on\n' 'the Review Board server. Displaying review ' 'requests from all repositories.') requests = api_root.get_review_requests(**query_args) for request in requests.all_items: if request.draft: self.output_draft(request, request.draft[0]) else: self.output_request(request)
class Patch(Command): """Applies a specific patch from a RB server. The patch file indicated by the request id is downloaded from the server and then applied locally.""" name = 'patch' author = 'The Review Board Project' args = '<review-request-id>' option_list = [ Option('-c', '--commit', dest='commit', action='store_true', default=False, help='Commits using information fetched ' 'from the review request (Git/Mercurial only).', added_in='0.5.3'), Option('-C', '--commit-no-edit', dest='commit_no_edit', action='store_true', default=False, help='Commits using information fetched ' 'from the review request (Git/Mercurial only). ' 'This differs from --commit by not invoking the editor ' 'to modify the commit message.'), Option('--diff-revision', dest='diff_revision', metavar='REVISION', default=None, help='The Review Board diff revision ID to use for the patch.'), Option('--px', dest='px', metavar='NUM', default=None, help="Strips the given number of paths from filenames in the " "diff. Equivalent to patch's `-p` argument."), Option('--print', dest='patch_stdout', action='store_true', default=False, help='Prints the patch to standard output instead of applying ' 'it to the tree.', added_in='0.5.3'), Option('-R', '--revert', dest='revert_patch', action='store_true', default=False, help='Revert the given patch instead of applying it.\n' 'This feature does not work with Bazaar or Mercurial ' 'repositories.', added_in='0.7.3'), Command.server_options, Command.repository_options, ] def get_patch(self, request_id, api_root, diff_revision=None): """Return the diff as a string, the used diff revision and its basedir. If a diff revision is not specified, then this will look at the most recent diff. """ try: diffs = api_root.get_diffs(review_request_id=request_id) except APIError as e: raise CommandError('Error getting diffs: %s' % e) # Use the latest diff if a diff revision was not given. # Since diff revisions start a 1, increment by one, and # never skip a number, the latest diff revisions number # should be equal to the number of diffs. if diff_revision is None: diff_revision = diffs.total_results try: diff = diffs.get_item(diff_revision) diff_body = diff.get_patch().data base_dir = getattr(diff, 'basedir', None) or '' except APIError: raise CommandError('The specified diff revision does not exist.') return diff_body, diff_revision, base_dir def apply_patch(self, repository_info, tool, request_id, diff_revision, diff_file_path, base_dir, revert=False): """Apply patch patch_file and display results to user.""" if revert: print('Patch is being reverted from request %s with diff revision ' '%s.' % (request_id, diff_revision)) else: print('Patch is being applied from request %s with diff revision ' '%s.' % (request_id, diff_revision)) result = tool.apply_patch(diff_file_path, repository_info.base_path, base_dir, self.options.px, revert=revert) if result.patch_output: print() print(result.patch_output.strip()) print() if not result.applied: if revert: raise CommandError( 'Unable to revert the patch. The patch may be invalid, or ' 'there may be conflicts that could not be resolved.') else: raise CommandError( 'Unable to apply the patch. The patch may be invalid, or ' 'there may be conflicts that could not be resolved.') if result.has_conflicts: if result.conflicting_files: if revert: print('The patch was partially reverted, but there were ' 'conflicts in:') else: print('The patch was partially applied, but there were ' 'conflicts in:') print() for filename in result.conflicting_files: print(' %s' % filename) print() elif revert: print('The patch was partially reverted, but there were ' 'conflicts.') else: print('The patch was partially applied, but there were ' 'conflicts.') return False else: if revert: print('Successfully reverted patch.') else: print('Successfully applied patch.') return True def main(self, request_id): """Run the command.""" repository_info, tool = self.initialize_scm_tool( client_name=self.options.repository_type) if self.options.revert_patch and not tool.supports_patch_revert: raise CommandError('The %s backend does not support reverting ' 'patches.' % tool.name) server_url = self.get_server_url(repository_info, tool) api_client, api_root = self.get_api(server_url) self.setup_tool(tool, api_root=api_root) # Check if repository info on reviewboard server match local ones. repository_info = repository_info.find_server_repository_info(api_root) # Get the patch, the used patch ID and base dir for the diff diff_body, diff_revision, base_dir = self.get_patch( request_id, api_root, self.options.diff_revision) if self.options.patch_stdout: print(diff_body) else: try: if tool.has_pending_changes(): message = 'Working directory is not clean.' if not self.options.commit: print('Warning: %s' % message) else: raise CommandError(message) except NotImplementedError: pass tmp_patch_file = make_tempfile(diff_body) success = self.apply_patch(repository_info, tool, request_id, diff_revision, tmp_patch_file, base_dir, revert=self.options.revert_patch) if success and (self.options.commit or self.options.commit_no_edit): try: review_request = api_root.get_review_request( review_request_id=request_id, force_text_type='plain') except APIError as e: raise CommandError('Error getting review request %s: %s' % (request_id, e)) message = extract_commit_message(review_request) author = review_request.get_submitter() try: tool.create_commit(message, author, not self.options.commit_no_edit) print('Changes committed to current branch.') except NotImplementedError: raise CommandError('--commit is not supported with %s' % tool.name)
class Post(Command): """Create and update review requests.""" name = "post" author = "The Review Board Project" description = "Uploads diffs to create and update review requests." args = "[revisions]" GUESS_AUTO = 'auto' GUESS_YES = 'yes' GUESS_NO = 'no' GUESS_YES_INPUT_VALUES = (True, 'yes', 1, '1') GUESS_NO_INPUT_VALUES = (False, 'no', 0, '0') GUESS_CHOICES = (GUESS_AUTO, GUESS_YES, GUESS_NO) option_list = [ OptionGroup( name='Posting Options', description='Controls the behavior of a post, including what ' 'review request gets posted and how, and what ' 'happens after it is posted.', option_list=[ Option('-r', '--review-request-id', dest='rid', metavar='ID', default=None, help='Specifies the existing review request ID to ' 'update.'), Option('-u', '--update', dest='update', action='store_true', default=False, help='Automatically determines the existing review ' 'request to update.'), Option('-p', '--publish', dest='publish', action='store_true', default=False, config_key='PUBLISH', help='Immediately publishes the review request after ' 'posting.'), Option('-o', '--open', dest='open_browser', action='store_true', config_key='OPEN_BROWSER', default=False, help='Opens a web browser to the review request ' 'after posting.'), Option('--submit-as', dest='submit_as', metavar='USERNAME', config_key='SUBMIT_AS', default=None, help='The user name to use as the author of the ' 'review request, instead of the logged in user.'), Option('--change-only', dest='change_only', action='store_true', default=False, help='Updates fields from the change description, ' 'but does not upload a new diff ' '(Perforce/Plastic only).'), Option('--diff-only', dest='diff_only', action='store_true', default=False, help='Uploads a new diff, but does not update ' 'fields from the change description ' '(Perforce/Plastic only).'), ]), Command.server_options, Command.repository_options, OptionGroup( name='Review Request Field Options', description='Options for setting the contents of fields in the ' 'review request.', option_list=[ Option('-g', '--guess-fields', dest='guess_fields', action='store', config_key='GUESS_FIELDS', nargs='?', default=GUESS_AUTO, const=GUESS_YES, choices=GUESS_CHOICES, help='Short-hand for --guess-summary ' '--guess-description.'), Option('--guess-summary', dest='guess_summary', action='store', config_key='GUESS_SUMMARY', nargs='?', default=GUESS_AUTO, const=GUESS_YES, choices=GUESS_CHOICES, help='Generates the Summary field based on the ' 'commit messages (Bazaar/Git/Mercurial only).'), Option('--guess-description', dest='guess_description', action='store', config_key='GUESS_DESCRIPTION', nargs='?', default=GUESS_AUTO, const=GUESS_YES, choices=GUESS_CHOICES, help='Generates the Description field based on the ' 'commit messages (Bazaar/Git/Mercurial only).'), Option('--change-description', default=None, help='A description of what changed in this update ' 'of the review request. This is ignored for new ' 'review requests.'), Option('--summary', dest='summary', default=None, help='The new contents for the Summary field.'), Option('--description', dest='description', default=None, help='The new contents for the Description field.'), Option('--description-file', dest='description_file', default=None, metavar='FILENAME', help='A text file containing the new contents for the ' 'Description field.'), Option('--testing-done', dest='testing_done', default=None, help='The new contents for the Testing Done field.'), Option('--testing-done-file', dest='testing_file', default=None, metavar='FILENAME', help='A text file containing the new contents for the ' 'Testing Done field.'), Option('--branch', dest='branch', config_key='BRANCH', default=None, help='The branch the change will be committed on.'), Option('--bugs-closed', dest='bugs_closed', default=None, help='The comma-separated list of bug IDs closed.'), Option('--target-groups', dest='target_groups', config_key='TARGET_GROUPS', default=None, help='The names of the groups that should perform the ' 'review.'), Option('--target-people', dest='target_people', config_key='TARGET_PEOPLE', default=None, help='The usernames of the people who should perform ' 'the review.'), Option('--depends-on', dest='depends_on', config_key='DEPENDS_ON', default=None, help='The new contents for the Depends On field.'), Option('--markdown', dest='markdown', action='store_true', config_key='MARKDOWN', default=False, help='Specifies if the summary and description should ' 'be interpreted as Markdown-formatted text ' '(Review Board 2.0+ only).'), ]), Command.diff_options, Command.perforce_options, Command.subversion_options, ] def post_process_options(self): # -g implies --guess-summary and --guess-description if self.options.guess_fields: self.options.guess_fields = self.normalize_guess_value( self.options.guess_fields, '--guess-fields') self.options.guess_summary = self.options.guess_fields self.options.guess_description = self.options.guess_fields if self.options.revision_range: raise CommandError( 'The --revision-range argument has been removed. To post a ' 'diff for one or more specific revisions, pass those ' 'revisions as arguments. For more information, see the ' 'RBTools 0.6 Release Notes.') if self.options.svn_changelist: raise CommandError( 'The --svn-changelist argument has been removed. To use a ' 'Subversion changelist, pass the changelist name as an ' 'additional argument after the command.') # Only one of --description and --description-file can be used if self.options.description and self.options.description_file: raise CommandError("The --description and --description-file " "options are mutually exclusive.\n") # If --description-file is used, read that file if self.options.description_file: if os.path.exists(self.options.description_file): fp = open(self.options.description_file, "r") self.options.description = fp.read() fp.close() else: raise CommandError( "The description file %s does not exist.\n" % self.options.description_file) # Only one of --testing-done and --testing-done-file can be used if self.options.testing_done and self.options.testing_file: raise CommandError("The --testing-done and --testing-done-file " "options are mutually exclusive.\n") # If --testing-done-file is used, read that file if self.options.testing_file: if os.path.exists(self.options.testing_file): fp = open(self.options.testing_file, "r") self.options.testing_done = fp.read() fp.close() else: raise CommandError("The testing file %s does not exist.\n" % self.options.testing_file) # If we have an explicitly specified summary, override # --guess-summary if self.options.summary: self.options.guess_summary = self.GUESS_NO else: self.options.guess_summary = self.normalize_guess_value( self.options.guess_summary, '--guess-summary') # If we have an explicitly specified description, override # --guess-description if self.options.description: self.options.guess_description = self.GUESS_NO else: self.options.guess_description = self.normalize_guess_value( self.options.guess_description, '--guess-description') # If we have an explicitly specified review request ID, override # --update if self.options.rid and self.options.update: self.options.update = False def normalize_guess_value(self, guess, arg_name): if guess in self.GUESS_YES_INPUT_VALUES: return self.GUESS_YES elif guess in self.GUESS_NO_INPUT_VALUES: return self.GUESS_NO elif guess == self.GUESS_AUTO: return guess else: raise CommandError('Invalid value "%s" for argument "%s"' % (guess, arg_name)) def get_repository_path(self, repository_info, api_root): """Get the repository path from the server. This will compare the paths returned by the SCM client with those one the server, and return the first match. """ if isinstance(repository_info.path, list): repositories = api_root.get_repositories() try: while True: for repo in repositories: if repo['path'] in repository_info.path: repository_info.path = repo['path'] raise StopIteration() repositories = repositories.get_next() except StopIteration: pass if isinstance(repository_info.path, list): error_str = [ 'There was an error creating this review request.\n', '\n', 'There was no matching repository path found on the server.\n', 'Unknown repository paths found:\n', ] for foundpath in repository_info.path: error_str.append('\t%s\n' % foundpath) error_str += [ 'Ask the administrator to add one of these repositories\n', 'to the Review Board server.\n', ] raise CommandError(''.join(error_str)) return repository_info.path def get_draft_or_current_value(self, field_name, review_request): """Returns the draft or current field value from a review request. If a draft exists for the supplied review request, return the draft's field value for the supplied field name, otherwise return the review request's field value for the supplied field name. """ if review_request.draft: fields = review_request.draft[0] else: fields = review_request return fields[field_name] def get_possible_matches(self, review_requests, summary, description, limit=5): """Returns a sorted list of tuples of score and review request. Each review request is given a score based on the summary and description provided. The result is a sorted list of tuples containing the score and the corresponding review request, sorted by the highest scoring review request first. """ candidates = [] # Get all potential matches. try: while True: for review_request in review_requests: summary_pair = (self.get_draft_or_current_value( 'summary', review_request), summary) description_pair = (self.get_draft_or_current_value( 'description', review_request), description) score = Score.get_match(summary_pair, description_pair) candidates.append((score, review_request)) review_requests = review_requests.get_next() except StopIteration: pass # Sort by summary and description on descending rank. sorted_candidates = sorted( candidates, key=lambda m: (m[0].summary_score, m[0].description_score), reverse=True) return sorted_candidates[:limit] def num_exact_matches(self, possible_matches): """Returns the number of exact matches in the possible match list.""" count = 0 for score, request in possible_matches: if score.is_exact_match(): count += 1 return count def guess_existing_review_request_id(self, repository_info, api_root, api_client): """Try to guess the existing review request ID if it is available. The existing review request is guessed by comparing the existing summary and description to the current post's summary and description, respectively. The current post's summary and description are guessed if they are not provided. If the summary and description exactly match those of an existing review request, the ID for which is immediately returned. Otherwise, the user is prompted to select from a list of potential matches, sorted by the highest ranked match first. """ user = get_user(api_client, api_root, auth_required=True) repository_id = get_repository_id(repository_info, api_root, self.options.repository_name) try: # Get only pending requests by the current user for this # repository. review_requests = api_root.get_review_requests( repository=repository_id, from_user=user.username, status='pending', expand='draft') if not review_requests: raise CommandError('No existing review requests to update for ' 'user %s.' % user.username) except APIError, e: raise CommandError('Error getting review requests for user ' '%s: %s' % (user.username, e)) summary = self.options.summary description = self.options.description if not summary or not description: try: commit_message = self.get_commit_message() if commit_message: if not summary: summary = commit_message['summary'] if not description: description = commit_message['description'] except NotImplementedError: raise CommandError('--summary and --description are required.') possible_matches = self.get_possible_matches(review_requests, summary, description) exact_match_count = self.num_exact_matches(possible_matches) for score, review_request in possible_matches: # If the score is the only exact match, return the review request # ID without confirmation, otherwise prompt. if score.is_exact_match() and exact_match_count == 1: return review_request.id else: question = ("Update Review Request #%s: '%s'? " % (review_request.id, self.get_draft_or_current_value( 'summary', review_request))) if confirm(question): return review_request.id return None
class Alias(Command): """A command for managing aliases defined in .reviewboardrc files.""" name = 'alias' author = 'The Review Board Project' description = 'Manage aliases defined in .reviewboardrc files.' option_list = [ Option('--list', action='store_true', dest='list_aliases', default=False, help='List all aliases defined in .reviewboardrc files.'), Option('--dry-run', metavar='ALIAS', dest='dry_run_alias', default=None, help='Print the command as it would be executed with the given ' 'command-line arguments.'), ] def list_aliases(self): """Print a list of .reviewboardrc aliases to the command line. This function shows in which file each alias is defined in and if the alias is valid (that is, if it won't be executable because an rbt command exists with the same name). """ # A mapping of configuration file paths to aliases. aliases = defaultdict(dict) # A mapping of aliases to configuration file paths. This allows us to # determine where aliases are overridden. predefined_aliases = {} config_paths = get_config_paths() for config_path in config_paths: config = parse_config_file(config_path) if 'ALIASES' in config: for alias_name, alias_cmd in six.iteritems(config['ALIASES']): predefined = alias_name in predefined_aliases aliases[config_path][alias_name] = { 'command': alias_cmd, 'overridden': predefined, 'invalid': command_exists(alias_name), } if not predefined: predefined_aliases[alias_name] = config_path for config_path in config_paths: if aliases[config_path]: self.stdout.write('[%s]' % config_path) for alias_name, entry in six.iteritems(aliases[config_path]): self.stdout.write(' %s = %s' % (alias_name, entry['command'])) if entry['invalid']: self.stdout.write(' !! This alias is overridden ' 'by an rbt command !!') elif entry['overridden']: self.stdout.write(' !! This alias is overridden ' 'by another alias in "%s" !!' % predefined_aliases[alias_name]) self.stdout.new_line() def main(self, *args): """Run the command.""" if ((self.options.list_aliases and self.options.dry_run_alias) or not (self.options.list_aliases or self.options.dry_run_alias)): raise CommandError('You must provide exactly one of --list or ' '--dry-run.') if self.options.list_aliases: self.list_aliases() elif self.options.dry_run_alias: try: alias = self.config['ALIASES'][self.options.dry_run_alias] except KeyError: raise CommandError('No such alias "%s"' % self.options.dry_run_alias) command = expand_alias(alias, args)[0] self.stdout.write(list2cmdline(command))
class Land(Command): """Land changes from a review request onto the remote repository. This command takes a review request, applies it to a feature branch, merges it with the specified destination branch, and pushes the changes to an upstream repository. Notes: The review request needs to be approved first. ``--local`` option can be used to skip the patching step. """ name = 'land' author = 'The Review Board Project' args = '[<branch-name>]' option_list = [ Option('--dest', dest='destination_branch', default=None, config_key='LAND_DEST_BRANCH', help='Specifies the destination branch to land changes on.'), Option('-r', '--review-request-id', dest='rid', metavar='ID', default=None, help='Specifies the review request ID.'), Option('--local', dest='is_local', action='store_true', default=None, help='Forces the change to be merged without patching, if ' 'merging a local branch. Defaults to true unless ' '--review-request-id is used.'), Option('-p', '--push', dest='push', action='store_true', default=False, config_key='LAND_PUSH', help='Pushes the branch after landing the change.'), Option('-n', '--no-push', dest='push', action='store_false', default=False, config_key='LAND_PUSH', help='Prevents pushing the branch after landing the change, ' 'if pushing is enabled by default.'), Option('--squash', dest='squash', action='store_true', default=False, config_key='LAND_SQUASH', help='Squashes history into a single commit.'), Option( '--no-squash', dest='squash', action='store_false', default=False, config_key='LAND_SQUASH', help='Disables squashing history into a single commit, choosing ' 'instead to merge the branch, if squashing is enabled by ' 'default.'), Option('-e', '--edit', dest='edit', action='store_true', default=False, help='Invokes the editor to edit the commit message before ' 'landing the change.'), Option('--delete-branch', dest='delete_branch', action='store_true', config_key='LAND_DELETE_BRANCH', default=True, help="Deletes the local branch after it's landed. Only used if " "landing a local branch. This is the default."), Option('--no-delete-branch', dest='delete_branch', action='store_false', config_key='LAND_DELETE_BRANCH', default=True, help="Prevents the local branch from being deleted after it's " "landed."), Option('--dry-run', dest='dry_run', action='store_true', default=False, help='Simulates the landing of a change, without actually ' 'making any changes to the tree.'), Command.server_options, Command.repository_options, ] def patch(self, review_request_id): patch_command = [RB_MAIN, 'patch'] patch_command.extend(build_rbtools_cmd_argv(self.options)) if self.options.edit: patch_command.append('-c') else: patch_command.append('-C') patch_command.append(review_request_id) p = subprocess.Popen(patch_command) rc = p.wait() if rc: die('Failed to execute command: %s' % patch_command) def main(self, branch_name=None, *args): """Run the command.""" self.cmd_args = list(args) if branch_name: self.cmd_args.insert(0, branch_name) repository_info, self.tool = self.initialize_scm_tool( client_name=self.options.repository_type) server_url = self.get_server_url(repository_info, self.tool) api_client, api_root = self.get_api(server_url) self.setup_tool(self.tool, api_root=api_root) dry_run = self.options.dry_run # Check if repository info on reviewboard server match local ones. repository_info = repository_info.find_server_repository_info(api_root) if (not self.tool.can_merge or not self.tool.can_push_upstream or not self.tool.can_delete_branch): raise CommandError( "This command does not support %s repositories." % self.tool.name) if self.tool.has_pending_changes(): raise CommandError('Working directory is not clean.') if self.options.rid: request_id = self.options.rid is_local = branch_name is not None else: request_id = guess_existing_review_request_id( repository_info, self.options.repository_name, api_root, api_client, self.tool, get_revisions(self.tool, self.cmd_args), guess_summary=False, guess_description=False, is_fuzzy_match_func=self._ask_review_request_match) if not request_id: raise CommandError('Could not determine the existing review ' 'request URL to land.') is_local = True if self.options.is_local is not None: is_local = self.options.is_local destination_branch = self.options.destination_branch if not destination_branch: raise CommandError('Please specify a destination branch.') if is_local: if branch_name is None: branch_name = self.tool.get_current_branch() if branch_name == destination_branch: raise CommandError('The local branch cannot be merged onto ' 'itself. Try a different local branch or ' 'destination branch.') review_request = get_review_request(request_id, api_root) try: is_rr_approved = review_request.approved approval_failure = review_request.approval_failure except AttributeError: # The Review Board server is an old version (pre-2.0) that # doesn't support the `approved` field. Determining it manually. if review_request.ship_it_count == 0: is_rr_approved = False approval_failure = \ 'The review request has not been marked "Ship It!"' else: is_rr_approved = True finally: if not is_rr_approved: raise CommandError(approval_failure) if is_local: review_commit_message = extract_commit_message(review_request) author = review_request.get_submitter() if self.options.squash: print('Squashing branch "%s" into "%s"' % (branch_name, destination_branch)) else: print('Merging branch "%s" into "%s"' % (branch_name, destination_branch)) if not dry_run: try: self.tool.merge(branch_name, destination_branch, review_commit_message, author, self.options.squash, self.options.edit) except MergeError as e: raise CommandError(str(e)) if self.options.delete_branch: print('Deleting merged branch "%s"' % branch_name) if not dry_run: self.tool.delete_branch(branch_name, merged_only=False) else: print('Applying patch from review request %s' % request_id) if not dry_run: self.patch(request_id) if self.options.push: print('Pushing branch "%s" upstream' % destination_branch) if not dry_run: try: self.tool.push_upstream(destination_branch) except PushError as e: raise CommandError(str(e)) print('Review request %s has landed on "%s".' % (request_id, destination_branch)) def _ask_review_request_match(self, review_request): return confirm('Land Review Request #%s: "%s"? ' % (review_request.id, get_draft_or_current_value('summary', review_request)))
class Status(Command): """Display review requests for the current repository.""" name = "status" author = "The Review Board Project" description = "Output a list of your pending review requests." args = "" option_list = [ Option("--server", dest="server", metavar="SERVER", config_key="REVIEWBOARD_URL", default=None, help="specify a different Review Board server to use"), Option("--username", dest="username", metavar="USERNAME", config_key="USERNAME", default=None, help="user name to be supplied to the Review Board server"), Option("--password", dest="password", metavar="PASSWORD", config_key="PASSWORD", default=None, help="password to be supplied to the Review Board server"), Option("--all", dest="all_repositories", action="store_true", default=False, help="Show review requests for all repositories instead " "of the detected repository."), Option('--repository-type', dest='repository_type', config_key="REPOSITORY_TYPE", default=None, help='the type of repository in the current directory. ' 'In most cases this should be detected ' 'automatically but some directory structures ' 'containing multiple repositories require this ' 'option to select the proper type. Valid ' 'values include bazaar, clearcase, cvs, git, ' 'mercurial, perforce, plastic, and svn.'), ] def output_request(self, request): print " r/%s - %s" % (request.id, request.summary) def output_draft(self, request, draft): print " * r/%s - %s" % (request.id, draft.summary) def main(self): repository_info, tool = self.initialize_scm_tool( client_name=self.options.repository_type) server_url = self.get_server_url(repository_info, tool) api_client, api_root = self.get_api(server_url) self.setup_tool(tool, api_root=api_root) user = get_user(api_client, api_root, auth_required=True) query_args = { 'from_user': user.username, 'status': 'pending', 'expand': 'draft', } if not self.options.all_repositories: repo_id = get_repository_id(repository_info, api_root, repository_name=self.config.get( 'REPOSITORY', None)) if repo_id: query_args['repository'] = repo_id else: logging.warning('The repository detected in the current ' 'directory was not found on\n' 'the Review Board server. Displaying review ' 'requests from all repositories.') requests = api_root.get_review_requests(**query_args) try: while True: for request in requests: if request.draft: self.output_draft(request, request.draft[0]) else: self.output_request(request) requests = requests.get_next(**query_args) except StopIteration: pass
import argparse import os import signal import subprocess import sys import pkg_resources from rbtools import get_version_string from rbtools.commands import find_entry_point_for_command, Option, RB_MAIN from rbtools.utils.aliases import run_alias from rbtools.utils.filesystem import load_config GLOBAL_OPTIONS = [ Option('-v', '--version', action='version', version='RBTools %s' % get_version_string()), Option('-h', '--help', action='store_true', dest='help', default=False), Option('command', nargs=argparse.REMAINDER, help='The RBTools command to execute, and any arguments. ' '(See below)'), ] def build_help_text(command_class): """Generate help text from a command class.""" command = command_class() parser = command.create_parser({}) return parser.format_help()
class Close(Command): """Close a specific review request as discarded or submitted. By default, the command will change the status to submitted. The user can provide an optional description for this action. """ name = 'close' author = 'The Review Board Project' needs_api = True args = '<review-request-id>' option_list = [ Option('--close-type', dest='close_type', default=SUBMITTED, help='Either `submitted` or `discarded`.'), Option('--description', dest='description', default=None, help='An optional description accompanying the change.'), Command.server_options, Command.repository_options, ] def check_valid_type(self, close_type): """Check if the user specificed a proper type. Type must either be 'discarded' or 'submitted'. If the type is wrong, the command will stop and alert the user. """ if close_type not in (SUBMITTED, DISCARDED): raise CommandError('%s is not valid type. Try "%s" or "%s"' % (self.options.close_type, SUBMITTED, DISCARDED)) def main(self, review_request_id): """Run the command.""" close_type = self.options.close_type self.check_valid_type(close_type) try: review_request = self.api_root.get_review_request( review_request_id=review_request_id) except APIError as e: raise CommandError('Error getting review request %s: %s' % (review_request_id, e)) if review_request.status == close_type: raise CommandError('Review request #%s is already %s.' % (review_request_id, close_type)) if self.options.description: review_request = review_request.update( status=close_type, description=self.options.description) else: review_request = review_request.update(status=close_type) self.stdout.write('Review request #%s is set to %s.' % (review_request_id, review_request.status)) self.json.add('review_request', review_request_id) self.json.add('status', review_request.status)
class Diff(Command): """Prints a diff to the terminal.""" name = "diff" author = "The Review Board Project" args = "[changenum]" option_list = [ Option("--server", dest="server", metavar="SERVER", config_key="REVIEWBOARD_URL", default=None, help="specify a different Review Board server to use"), Option("--revision-range", dest="revision_range", default=None, help="generate the diff for review based on given " "revision range"), Option("--parent", dest="parent_branch", metavar="PARENT_BRANCH", help="the parent branch this diff should be against " "(only available if your repository supports " "parent diffs)"), Option("--tracking-branch", dest="tracking", metavar="TRACKING", help="Tracking branch from which your branch is derived " "(git only, defaults to origin/master)"), Option("--svn-show-copies-as-adds", dest="svn_show_copies_as_adds", metavar="y/n", default=None, help="don't diff copied or moved files with their source"), Option('--svn-changelist', dest='svn_changelist', default=None, help='generate the diff for review based on a local SVN ' 'changelist'), Option("--repository-url", dest="repository_url", help="the url for a repository for creating a diff " "outside of a working copy (currently only " "supported by Subversion with --revision-range or " "--diff-filename and ClearCase with relative " "paths outside the view). For git, this specifies" "the origin url of the current repository, " "overriding the origin url supplied by the git " "client."), Option("--username", dest="username", metavar="USERNAME", config_key="USERNAME", default=None, help="user name to be supplied to the Review Board server"), Option("--password", dest="password", metavar="PASSWORD", config_key="PASSWORD", default=None, help="password to be supplied to the Review Board server"), Option('--repository-type', dest='repository_type', config_key="REPOSITORY_TYPE", default=None, help='the type of repository in the current directory. ' 'In most cases this should be detected ' 'automatically but some directory structures ' 'containing multiple repositories require this ' 'option to select the proper type. Valid ' 'values include bazaar, clearcase, cvs, git, ' 'mercurial, perforce, plastic, and svn.'), ] def main(self, *args): """Print the diff to terminal.""" # The 'args' tuple must be made into a list for some of the # SCM Clients code. See comment in post. args = list(args) repository_info, tool = self.initialize_scm_tool( client_name=self.options.repository_type) server_url = self.get_server_url(repository_info, tool) api_client, api_root = self.get_api(server_url) self.setup_tool(tool, api_root=api_root) diff, parent_diff = get_diff( tool, repository_info, revision_range=self.options.revision_range, svn_changelist=self.options.svn_changelist, files=args) if diff: print diff
class Land(Command): """Land changes from a review request onto the remote repository. This command takes a review request, applies it to a feature branch, merges it with the specified destination branch, and pushes the changes to an upstream repository. Notes: The review request needs to be approved first. ``--local`` option can be used to skip the patching step. """ name = 'land' author = 'The Review Board Project' args = '[<branch-name>]' option_list = [ Option('--dest', dest='destination_branch', default=None, config_key='LAND_DEST_BRANCH', help='Specifies the destination branch to land changes on.'), Option('-r', '--review-request-id', dest='rid', metavar='ID', default=None, help='Specifies the review request ID.'), Option('--local', dest='is_local', action='store_true', default=None, help='Forces the change to be merged without patching, if ' 'merging a local branch. Defaults to true unless ' '--review-request-id is used.'), Option('-p', '--push', dest='push', action='store_true', default=False, config_key='LAND_PUSH', help='Pushes the branch after landing the change.'), Option('-n', '--no-push', dest='push', action='store_false', default=False, config_key='LAND_PUSH', help='Prevents pushing the branch after landing the change, ' 'if pushing is enabled by default.'), Option('--squash', dest='squash', action='store_true', default=False, config_key='LAND_SQUASH', help='Squashes history into a single commit.'), Option('--no-squash', dest='squash', action='store_false', default=False, config_key='LAND_SQUASH', help='Disables squashing history into a single commit, ' 'choosing instead to merge the branch, if squashing is ' 'enabled by default.'), Option('-e', '--edit', dest='edit', action='store_true', default=False, help='Invokes the editor to edit the commit message before ' 'landing the change.'), Option('--delete-branch', dest='delete_branch', action='store_true', config_key='LAND_DELETE_BRANCH', default=True, help="Deletes the local branch after it's landed. Only used if " "landing a local branch. This is the default."), Option('--no-delete-branch', dest='delete_branch', action='store_false', config_key='LAND_DELETE_BRANCH', default=True, help="Prevents the local branch from being deleted after it's " "landed."), Option('--dry-run', dest='dry_run', action='store_true', default=False, help='Simulates the landing of a change, without actually ' 'making any changes to the tree.'), Option('--recursive', dest='recursive', action='store_true', default=False, help='Recursively fetch patches for review requests that the ' 'specified review request depends on. This is equivalent ' 'to calling "rbt patch" for each of those review ' 'requests.', added_in='0.8.0'), Command.server_options, Command.repository_options, Command.branch_options, ] def patch(self, review_request_id): """Patch a single review request's diff using rbt patch.""" patch_command = [RB_MAIN, 'patch'] patch_command.extend(build_rbtools_cmd_argv(self.options)) if self.options.edit: patch_command.append('-c') else: patch_command.append('-C') patch_command.append(six.text_type(review_request_id)) rc, output = execute(patch_command, ignore_errors=True, return_error_code=True) if rc: raise CommandError('Failed to execute "rbt patch":\n%s' % output) def can_land(self, review_request): """Determine if the review request is land-able. A review request can be landed if it is approved or, if the Review Board server does not keep track of approval, if the review request has a ship-it count. This function returns the error with landing the review request or None if it can be landed. """ try: is_rr_approved = review_request.approved approval_failure = review_request.approval_failure except AttributeError: # The Review Board server is an old version (pre-2.0) that # doesn't support the `approved` field. Determine it manually. if review_request.ship_it_count == 0: is_rr_approved = False approval_failure = \ 'The review request has not been marked "Ship It!"' else: is_rr_approved = True except Exception as e: logging.exception( 'Unexpected error while looking up review request ' 'approval state: %s', e) return ('An error was encountered while executing the land ' 'command.') finally: if not is_rr_approved: return approval_failure return None def land(self, destination_branch, review_request, source_branch=None, squash=False, edit=False, delete_branch=True, dry_run=False): """Land an individual review request.""" if source_branch: review_commit_message = extract_commit_message(review_request) author = review_request.get_submitter() if squash: print('Squashing branch "%s" into "%s".' % (source_branch, destination_branch)) else: print('Merging branch "%s" into "%s".' % (source_branch, destination_branch)) if not dry_run: try: self.tool.merge(source_branch, destination_branch, review_commit_message, author, squash, edit) except MergeError as e: raise CommandError(six.text_type(e)) if delete_branch: print('Deleting merged branch "%s".' % source_branch) if not dry_run: self.tool.delete_branch(source_branch, merged_only=False) else: print('Applying patch from review request %s.' % review_request.id) if not dry_run: self.patch(review_request.id) print('Review request %s has landed on "%s".' % (review_request.id, self.options.destination_branch)) def main(self, branch_name=None, *args): """Run the command.""" self.cmd_args = list(args) if branch_name: self.cmd_args.insert(0, branch_name) repository_info, self.tool = self.initialize_scm_tool( client_name=self.options.repository_type) server_url = self.get_server_url(repository_info, self.tool) api_client, api_root = self.get_api(server_url) self.setup_tool(self.tool, api_root=api_root) # Check if repository info on reviewboard server match local ones. repository_info = repository_info.find_server_repository_info(api_root) if (not self.tool.can_merge or not self.tool.can_push_upstream or not self.tool.can_delete_branch): raise CommandError( 'This command does not support %s repositories.' % self.tool.name) if self.tool.has_pending_changes(): raise CommandError('Working directory is not clean.') if not self.options.destination_branch: raise CommandError('Please specify a destination branch.') if self.options.rid: is_local = branch_name is not None review_request_id = self.options.rid else: review_request = guess_existing_review_request( repository_info, self.options.repository_name, api_root, api_client, self.tool, get_revisions(self.tool, self.cmd_args), guess_summary=False, guess_description=False, is_fuzzy_match_func=self._ask_review_request_match) if not review_request or not review_request.id: raise CommandError('Could not determine the existing review ' 'request URL to land.') review_request_id = review_request.id is_local = True review_request = get_review_request(review_request_id, api_root) if self.options.is_local is not None: is_local = self.options.is_local if is_local: if branch_name is None: branch_name = self.tool.get_current_branch() if branch_name == self.options.destination_branch: raise CommandError('The local branch cannot be merged onto ' 'itself. Try a different local branch or ' 'destination branch.') else: branch_name = None land_error = self.can_land(review_request) if land_error is not None: raise CommandError('Cannot land review request %s: %s' % (review_request_id, land_error)) if self.options.recursive: # The dependency graph shows us which review requests depend on # which other ones. What we are actually after is the order to land # them in, which is the topological sorting order of the converse # graph. It just so happens that if we reverse the topological sort # of a graph, it is a valid topological sorting of the converse # graph, so we don't have to compute the converse graph. dependency_graph = review_request.build_dependency_graph() dependencies = toposort(dependency_graph)[1:] if dependencies: print( 'Recursively landing dependencies of review request %s.' % review_request_id) for dependency in dependencies: land_error = self.can_land(dependency) if land_error is not None: raise CommandError( 'Aborting recursive land of review request %s.\n' 'Review request %s cannot be landed: %s' % (review_request_id, dependency.id, land_error)) for dependency in reversed(dependencies): self.land(self.options.destination_branch, dependency, None, self.options.squash, self.options.edit, self.options.delete_branch, self.options.dry_run) self.land(self.options.destination_branch, review_request, branch_name, self.options.squash, self.options.edit, self.options.delete_branch, self.options.dry_run) if self.options.push: print('Pushing branch "%s" upstream' % self.options.destination_branch) if not self.options.dry_run: try: self.tool.push_upstream(self.options.destination_branch) except PushError as e: raise CommandError(six.text_type(e)) def _ask_review_request_match(self, review_request): return confirm('Land Review Request #%s: "%s"? ' % (review_request.id, get_draft_or_current_value('summary', review_request)))
class Diff(Command): """Prints a diff to the terminal.""" name = "diff" author = "The Review Board Project" args = "[changenum]" option_list = [ Option("--server", dest="server", metavar="SERVER", config_key="REVIEWBOARD_URL", default=None, help="specify a different Review Board server to use"), Option("--revision-range", dest="revision_range", default=None, help="generate the diff for review based on given " "revision range"), Option("--parent", dest="parent_branch", metavar="PARENT_BRANCH", help="the parent branch this diff should be against " "(only available if your repository supports " "parent diffs)"), Option("--tracking-branch", dest="tracking", metavar="TRACKING", help="Tracking branch from which your branch is derived " "(git only, defaults to origin/master)"), Option('--svn-changelist', dest='svn_changelist', default=None, help='generate the diff for review based on a local SVN ' 'changelist'), Option("--repository-url", dest="repository_url", help="the url for a repository for creating a diff " "outside of a working copy (currently only " "supported by Subversion with --revision-range or " "--diff-filename and ClearCase with relative " "paths outside the view). For git, this specifies" "the origin url of the current repository, " "overriding the origin url supplied by the git " "client."), Option("-d", "--debug", action="store_true", dest="debug", config_key="DEBUG", default=False, help="display debug output"), Option("--username", dest="username", metavar="USERNAME", config_key="USERNAME", default=None, help="user name to be supplied to the Review Board server"), Option("--password", dest="password", metavar="PASSWORD", config_key="PASSWORD", default=None, help="password to be supplied to the Review Board server"), ] def get_diff(self, *args): """Returns a diff as a string.""" repository_info, tool = self.initialize_scm_tool() server_url = self.get_server_url(repository_info, tool) root_resource = self.get_root(server_url) self.setup_tool(tool, api_root=root_resource) if self.options.revision_range: diff, parent_diff = tool.diff_between_revisions( self.options.revision_range, args, repository_info) elif self.options.svn_changelist: diff, parent_diff = tool.diff_changelist( self.options.svn_changelist) else: diff, parent_diff = tool.diff(list(args)) return diff def main(self, *args): """Print the diff to terminal.""" diff = self.get_diff(*args) if not diff: die("There don't seem to be any diffs!") print diff
import glob import os import pkg_resources import signal import subprocess import sys from rbtools import get_version_string from rbtools.commands import find_entry_point_for_command, Option, RB_MAIN from rbtools.utils.aliases import run_alias from rbtools.utils.filesystem import load_config GLOBAL_OPTIONS = [ Option('-v', '--version', action='version', version='RBTools %s (Python %d.%d.%d)' % (get_version_string(), sys.version_info[:3][0], sys.version_info[:3][1], sys.version_info[:3][2])), Option('-h', '--help', action='store_true', dest='help', default=False), Option('command', nargs=argparse.REMAINDER, help='The RBTools command to execute, and any arguments. ' '(See below)'), ] def build_help_text(command_class): """Generate help text from a command class.""" command = command_class() parser = command.create_parser({})