def cmd_list_pr(assignee, label, limit, state, project, organization) -> None: """ Command for listing pull requests. devops: https://docs.microsoft.com/en-us/cli/azure/ext/azure-devops/repos/pr?view=azure-cli-latest#ext_azure_devops_az_repos_pr_list github cli: https://cli.github.com/manual/gh_pr_list """ # noqa assignee = replace_user_aliases(assignee) query = "az repos pr list " query += f'--status "{state}" ' query += f"--top {limit} " query += f'--org "{organization}" ' query += f'--project "{project}" ' query += f'--repository "{get_repo_name()}" ' # Generate a jmespath.org query # Example: # az repos pr list --status active --query "[*].{title: title, pullRequestID: pullRequestID, status: status, labels:labels[*].name, reviewers: reviewers[*].uniqueName} | [*].labels" -o jsonc # noqa jmespath_query = "[*].{title: title, pullRequestId: pullRequestId, creationDate: creationDate, status: status, labels:labels[*].name, reviewers: reviewers[*].uniqueName}" # noqa if assignee and label: jmespath_query += " | [? reviewers!=\`null\` && labels!=\`null\`]" # noqa jmespath_query += f" | [?{generate_jmespath(assignee, 'reviewers')} && {generate_jmespath(label, 'labels')}]" elif assignee: jmespath_query += " | [? reviewers!=\`null\`]" # noqa jmespath_query += f" | [?{generate_jmespath(assignee, 'reviewers')}]" elif label: jmespath_query += " | [? labels!=\`null\`]" # noqa jmespath_query += f" | [?{generate_jmespath(label, 'labels')}]" query += f'--query "{jmespath_query}" ' prs = run_command(query) if len(prs) == 0: msg = "[dark_orange3]>[/dark_orange3] No pull requests given filters used" console.print(msg) return table = Table(title=f"{state} Pull requests") table.add_column("ID", justify="right", style="cyan", no_wrap=True) table.add_column("Title", justify="right", style="cyan", no_wrap=True) table.add_column("Reviewers", justify="right", style="cyan", no_wrap=True) table.add_column("Created", justify="right", style="cyan", no_wrap=True) table.add_column("Status", justify="right", style="cyan", no_wrap=True) for pr in prs: reviewers = ", ".join(pr.get("reviewers")) # Get created date # Example: '2021-06-18T09:57:56.653886+00:00' created = pr.get("creationDate") created = datetime.datetime.strptime(created, "%Y-%m-%dT%H:%M:%S.%f%z") now = datetime.datetime.now(timezone.utc) created = timeago.format(created, now) table.add_row(str(pr.get("pullRequestId")), pr.get("title"), reviewers, created, pr.get("status")) console.print(table)
def cmd_init(reference_issue: str = ""): """ Create a .doing-cli-config file. Empty file if no reference_url is specified. Args: reference_issue: URL of work item to use as reference """ if os.path.exists(".doing-cli-config.yml"): console.print("File '.doing-cli-config.yml' already exists.") return if not reference_issue: required_params = { "organization": "", "project": "", "team": "", "area": "", "iteration": "", } with open(".doing-cli-config.yml", "w") as file: file.write("# doing cli configuration file\n") file.write("# docs: https://github.com/ing-bank/doing-cli\n\n") yaml.dump(required_params, file) console.print( "[dark_orange3]>[/dark_orange3] Created new .doing-cli-config.yml file" ) console.print( "\t[dark_orange3]>[/dark_orange3] Please fill in required parameters." ) return organization, project, item_id = parse_reference(reference_issue) organization = "https://dev.azure.com/" + organization required_params = {"organization": organization, "project": project} cmd = f"az boards work-item show --id {item_id} " cmd += f'--org "{organization}" ' workitem = run_command(cmd) workitem = workitem.get("fields") assert workitem is not None required_params["team"] = workitem.get("System.IterationLevel2") required_params["area"] = workitem.get("System.AreaPath") required_params["iteration"] = workitem.get("System.IterationPath") with open(".doing-cli-config.yml", "w") as file: file.write("# doing cli configuration file\n") file.write("# docs: https://github.com/ing-bank/doing-cli\n\n") yaml.dump(required_params, file) console.print( "[dark_orange3]>[/dark_orange3] Create new .doing-cli-config.yml file" ) console.print( f"\t[dark_orange3]>[/dark_orange3] Filled in required parameters using reference work item #{item_id}" )
def close(work_item_id): """Close a specific WORK_ITEM_ID. A '#' prefix is allowed. You can specify multiple IDs by separating with a space. """ organization = get_config("organization") state = "Closed" for id in work_item_id: id = str(id).lstrip("#") cmd = f"az boards work-item update --id {id} --state '{state}' " cmd += f"--org '{organization}'" result = run_command(cmd) assert result.get("fields").get("System.State") == state console.print( f"[dark_orange3]>[/dark_orange3] work item #{id} set to '{state}'")
def pipe(): """ Open latest pipeline runs for repository view. """ project = get_config("project") organization = get_config("organization") repo_pipes = run_command( f'az pipelines list --repository "{get_repo_name()}" --org "{organization}" -p "{project}"' ) if len(repo_pipes) == 0: console.print(f"{get_repo_name()} has no pipelines defined currently") return None pipeline_id = repo_pipes[0].get("id") click.launch(f"{organization}/{project}/_build?definitionId={pipeline_id}")
def close(pr_id): """ Close a specific PR_ID. PR_ID is the ID number of a pull request. '!' prefix is allowed. You can specify multiple IDs by separating with a space. """ organization = get_config("organization") state = "abandoned" for id in pr_id: id = str(id).lstrip("!") cmd = f'az repos pr update --id {id} --status "{state}" ' cmd += f'--org "{organization}"' result = run_command(cmd) assert result.get("status") == state console.print( f"[dark_orange3]>[/dark_orange3] pullrequest !{id} set to '{state}'" )
def policies(): """ Open repository policy settings. Will show the default branch policies by default. """ project = get_config("project") organization = get_config("organization") repo_name = get_repo_name() repo = run_command(f'az repos show --repository "{repo_name}"') repo_id = repo.get("id") assert len(repo_id) > 0 default_branch = repo.get("defaultBranch").split("/")[-1] assert len(default_branch) > 0 url = f"{organization}/{project}/_settings/repositories?repo={repo_id}" url += f"&_a=policiesMid&refs=refs%2Fheads%2F{default_branch}" click.launch(url)
def cmd_create_issue( title: str, mine: bool, assignee: str, body: str, type: str, label: str, parent: str, team: str, area: str, iteration: str, organization: str, project: str, story_points: str, ) -> int: """ Create a new issue. Reference: - [`az boards work-item create]`(https://docs.microsoft.com/en-us/cli/azure/boards/work-item?view=azure-cli-latest#az-boards-work-item-create) """ # noqa if mine and assignee: raise InputError( "You cannot use --mine in combination with specifying --assigned-to." ) if mine: assignee = get_az_devop_user_email() assignee = replace_user_aliases(assignee) validate_work_item_type(type) cmd = "az boards work-item create " cmd += f'--title "{title}" ' cmd += f'--type "{type}" ' if assignee: cmd += f'--assigned-to "{assignee}" ' if body: cmd += f'--description "{body}" ' if story_points != "" and label != "": cmd += f'--fields "Microsoft.VSTS.Scheduling.StoryPoints={story_points}" "System.Tags={label}" ' if story_points != "" and label == "": cmd += f'--fields "Microsoft.VSTS.Scheduling.StoryPoints={story_points}" ' if story_points == "" and label != "": cmd += f'--fields "System.Tags={label}" ' cmd += f'--area "{area}" --iteration "{iteration}" --project "{project}" --organization "{organization}"' work_item = run_command(cmd) work_item_id = work_item.get("id") console.print( f"[dark_orange3]>[/dark_orange3] Created work item {work_item_id} '[cyan]{title}[/cyan]' ({type})" ) console.print(f"\t[dark_orange3]>[/dark_orange3] added area-path '{area}'") console.print( f"\t[dark_orange3]>[/dark_orange3] added iteration-path '{iteration}'") if assignee: console.print( f"\t[dark_orange3]>[/dark_orange3] added assignee '{assignee}'") if label: console.print( f"\t[dark_orange3]>[/dark_orange3] added tags: '{label}'") if story_points: console.print( f"\t[dark_orange3]>[/dark_orange3] assigned {story_points} storypoints" ) if parent: cmd = "az boards work-item relation add " cmd += f"--id {work_item_id} " cmd += '--relation-type "parent" ' cmd += f"--target-id {parent} " cmd += f'--org "{organization}" ' run_command(cmd) console.print( f"\t[dark_orange3]>[/dark_orange3] added work item #{parent} as a parent" ) return work_item_id
def set_merge_strategy_policy(merge_strategy: str, organization: str, project: str) -> None: """ Set merge strategy policy if needed. """ if merge_strategy is None: return assert merge_strategy in [ "basic merge", "squash merge", "rebase and fast-forward", "rebase with merge commit" ] merge_settings = "" merge_settings += f"--allow-no-fast-forward {str(merge_strategy == 'basic merge').lower()} " merge_settings += f"--allow-rebase {str(merge_strategy == 'rebase and fast-forward').lower()} " merge_settings += f"--allow-rebase-merge {str(merge_strategy == 'rebase with merge commit').lower()} " merge_settings += f"--allow-squash {str(merge_strategy == 'squash merge').lower()} " repo_name = get_repo_name() repo = run_command(f'az repos show --repository "{repo_name}"') repo_id = repo.get("id") assert len(repo_id) > 0 default_branch = repo.get("defaultBranch").split("/")[-1] policies = run_command( f'az repos policy list --repository "{repo_id}" --branch "{default_branch}" -o json' ) policies = [ p for p in policies if p.get("type", {}).get("displayName") == "Require a merge strategy" ] enabled_policies = [p for p in policies if p.get("isEnabled") is True] # Create a new policies if len(enabled_policies) == 0: cmd = "az repos policy merge-strategy create --blocking true --enabled true " msg = "[dark_orange3]>[/dark_orange3] Set repository merge strategy " msg += f"on default branch '{default_branch}' to [cyan]'{merge_strategy}'[cyan]" # Update an existing policy, if needed if len(enabled_policies) == 1: policy_settings = enabled_policies[0].get("settings") policy_id = enabled_policies[0].get("id") # do we need to update the settings or are they correct already? if (policy_settings.get("allowNoFastForward") == (merge_strategy == "basic merge") and policy_settings.get("allowRebase") == (merge_strategy == "rebase and fast-forward") and policy_settings.get("allowRebaseMerge") == (merge_strategy == "rebase with merge commit") and policy_settings.get("allowSquash") == (merge_strategy == "squash merge")): # policy settings already OK. Don't do anything return cmd = f'az repos policy merge-strategy update --id "{policy_id}" --blocking true ' msg = "[dark_orange3]>[/dark_orange3] Updated repository merge strategy " msg += f"on default branch '{default_branch}' to [cyan]'{merge_strategy}'[cyan]" if len(enabled_policies) > 2: raise AssertionError( "There are multiple merge strategy policies already enabled, not sure what to do / if this can happen." ) # Add correct policy settings cmd += f'--branch {default_branch} --repository-id "{repo_id}" ' cmd += f'{merge_settings} --project "{project}" --org "{organization}" ' settings_url = f"{organization}/{project}/_settings/repositories?repo={repo_id}" settings_url += f"&_a=policiesMid&refs=refs%2Fheads%2F{default_branch}" # run command & report run_command(cmd) console.print(msg) console.print(f"\tView/edit policies manually: {settings_url}")
def cmd_create_pr( work_item_id: str, draft: bool, auto_complete: bool, self_approve: bool, reviewers: str, checkout: bool, delete_source_branch: bool, team: str, area: str, iteration: str, organization: str, project: str, ) -> int: """ Run command `doing create pr`. API doc: https://docs.microsoft.com/en-us/cli/azure/ext/azure-devops/repos/pr?view=azure-cli-latest#ext_azure_devops_az_repos_pr_create """ work_item_id = str(work_item_id).lstrip("#") # add self to reviewers & replace user aliases reviewers = f"{reviewers} @me".strip() reviewers = replace_user_aliases(reviewers) if checkout: check_uncommitted_work() repo_name = get_repo_name() user_email = get_az_devop_user_email() # Info on related work item work_item = run_command( f'az boards work-item show --id {work_item_id} --org "{organization}"') work_item_title = work_item.get("fields").get("System.Title") # Info in other linked PRs to work item active_related_pr_ids = [] relations = work_item.get("relations") if relations: for relation in relations: if relation.get("attributes").get("name") in ["Pull Request"]: # For example # url = 'vstfs:///Git/PullRequestId/bbd257b1-b8a9-1fc2-b123-1ea2cc23c333%2f4d2e1234-c1d0-1234-1f23-c1234d05d471%2f12345' # noqa # The bit after %2f is the pullrequestid (12345) related_pr_id = re.findall("^.*%2[fF]([0-9]+)$", relation.get("url"))[0] related_pr_id_status = run_command( f'az repos pr show --id {related_pr_id} --query "status" --org "{organization}"' ) if related_pr_id_status == "active": active_related_pr_ids.append(related_pr_id) related_pr_ids = ",".join(active_related_pr_ids) # Info on remote branches remote_branches = run_command( f'az repos ref list --repository "{repo_name}" --query "[].name" --org "{organization}" -p "{project}"' ) remote_branches = [ x.rpartition("/")[2] for x in remote_branches if x.startswith("refs/heads") ] # Find the default branch from which to create a new branch and target the pull request to cmd = f'az repos show --repository "{repo_name}" --org "{organization}" -p "{project}"' default_branch = run_command(cmd).get("defaultBranch", "refs/heads/master") # Create a new remote branch, only if it does yet exist cmd = f'az repos ref list --repository "{repo_name}" --query "[?name==\'{default_branch}\'].objectId" ' cmd += f'--org "{organization}" -p "{project}"' master_branch_object_id = run_command(cmd)[0] branch_name = f"{work_item_id}_{to_snake_case(remove_special_chars(work_item_title))}" if branch_name in remote_branches: console.print( f"[dark_orange3]>[/dark_orange3] Remote branch '[cyan]{branch_name}[/cyan]' already exists, using that one" ) # Check if there is not already an existing PR for this branch prs = run_command( f'az repos pr list -r "{repo_name}" -s {branch_name} --org "{organization}" -p "{project}"' ) if len(prs) >= 1: pr_id = prs[0].get("pullRequestId") console.print( f"[dark_orange3]>[/dark_orange3] Pull request {pr_id} already exists", f"for branch '[cyan]{branch_name}[/cyan]', aborting.", ) if not checkout and (get_git_current_branch() != branch_name): explain_checkout(branch_name) if checkout and (get_git_current_branch() != branch_name): git_checkout(branch_name) # TODO: # Users might get a # fatal: A branch named '<work_item_id>_<issue_title>' already exists. # if local branch already exists. # We could test to see if it is setup to track the remote branch, and if not set that right # Might help some less experienced git users. return pr_id else: cmd = f'az repos ref create --name "heads/{branch_name}" --repository "{repo_name}" ' cmd += f'--object-id "{master_branch_object_id}" -p "{project}" --org "{organization}"' branch = run_command(cmd) assert branch.get( "success" ), f"Could not create '{branch_name}'. Do you have contributor rights to the '{get_repo_name()}' repo?" # noqa console.print( f"[dark_orange3]>[/dark_orange3] Created remote branch '[cyan]{branch_name}[/cyan]'" ) # Check the PR merge strategy check_merge_strategy_policy() # Create the PR command = f'az repos pr create --repository "{repo_name}" ' command += f'--draft "{str(draft).lower()}" ' command += f'--work-items "{work_item_id}" ' command += f'--source-branch "{branch_name}" ' command += f'--title "{work_item_title}" ' command += f'--project "{project}" --organization "{organization}" ' # Some sensible defaults command += '--transition-work-items "true" ' command += f'--delete-source-branch "{str(delete_source_branch).lower()}" ' # auto-complete. command += f'--auto-complete "{str(auto_complete).lower()}" ' if reviewers != "": # Azure wants the format --reviewers 'one' 'two' 'three' az_reviewers = " ".join([f'"{x}"' for x in reviewers.split(" ")]) command += f"--reviewers {az_reviewers} " pr = run_command(command) # Report to user pr_id = pr.get("pullRequestId") console.print( f"[dark_orange3]>[/dark_orange3] Created pull request {pr_id} [cyan]'{work_item_title}'[cyan]" ) console.print( f"\t[dark_orange3]>[/dark_orange3] linked work item {work_item_id}") if draft: console.print( "\t[dark_orange3]>[/dark_orange3] marked as draft pull request") if auto_complete: console.print( "\t[dark_orange3]>[/dark_orange3] set auto-complete to True'") if delete_source_branch: console.print( "\t[dark_orange3]>[/dark_orange3] set to delete remote source branch after PR completion" ) if len(reviewers) > 0: console.print( f"\t[dark_orange3]>[/dark_orange3] added reviewers: '{reviewers}'") if self_approve: run_command( f'az repos pr set-vote --id {pr_id} --vote "approve" --org "{organization}"' ) console.print( f"\t[dark_orange3]>[/dark_orange3] Approved PR {pr_id} for '{user_email}'" ) if active_related_pr_ids: console.print( f"\t[dark_orange3]>[/dark_orange3] Note: work item has other active linked PRs: {related_pr_ids}" ) if not checkout: explain_checkout(branch_name) else: git_checkout(branch_name) return pr_id
def cmd_list( assignee: str, author: str, label: str, state: str, team: str, area: str, iteration: str, organization: str, project: str, work_item_type: str, show_state: bool, story_points: str = "", output_format: str = "table", ) -> None: """Run `doing list` command.""" # Get config settings assignee = replace_user_aliases(assignee) author = replace_user_aliases(author) query = work_item_query(assignee, author, label, state, area, iteration, work_item_type, story_points) work_items = run_command( f'az boards query --wiql "{query}" --org "{organization}" -p "{project}"' ) if len(work_items) == 0: msg = f"[dark_orange3]>[/dark_orange3] No issues in sprint '{iteration}' given filters used" console.print(msg) return if output_format == "array": wi_array = [str(x.get("id")) for x in work_items] console.print(" ".join(wi_array)) return workitem_prs = {} # type: Dict # Now for each work item we could get linked PRs # However, APIs requests are slow, and most work items don't have a PR. # Instead, we'll retrieve all active PRs and see which items are linked (less API calls) repo_name = get_repo_name() query = f'az repos pr list --repository "{repo_name}" --org "{organization}" -p "{project}" ' query += '--status active --query "[].pullRequestId"' active_pullrequest_ids = run_command(query) with Live( build_table( work_items=work_items, workitem_prs=workitem_prs, iteration=iteration, last_build=False, show_state=show_state, ), refresh_per_second=4, console=console, ) as live: # For each PR, get linked work items. Note that "az repos pr list --include-links" does not work :( # Posted issue on bug here: https://github.com/Azure/azure-cli-extensions/issues/2946 for pr_id in track(active_pullrequest_ids, description="Processing pull requests", transient=False): linked_workitems = run_command( f'az repos pr work-item list --id {pr_id} --query "[].id" --org "{organization}"', allow_verbose=False) for work_item in linked_workitems: if work_item in workitem_prs.keys(): workitem_prs[work_item].append(str(pr_id)) else: workitem_prs[work_item] = [str(pr_id)] live.update( build_table( work_items=work_items, workitem_prs=workitem_prs, iteration=iteration, last_build=False, show_state=show_state, )) live.update( build_table( work_items=work_items, workitem_prs=workitem_prs, iteration=iteration, last_build=False, show_state=show_state, ))