def test_me_alias(): """ Only runs in env where you can get user alias. """ # test @me alias text = get_az_devop_user_email() assert replace_user_aliases(text) == get_az_devop_user_email()
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 test_replace_user_aliases(tmp_path): """ Test replacing user aliases. """ # no aliases set just yet text = "john [email protected] [email protected]" assert replace_user_aliases( text) == "john [email protected] [email protected]" # Deduplication text = "john jane john" assert replace_user_aliases(text) == "john jane" text = "jane john jane john john" assert replace_user_aliases(text) == "jane john" config = { "user_aliases": { "john": "*****@*****.**", "jane": "*****@*****.**" } } with working_directory(tmp_path): with open(".doing-cli-config.yml", "w") as file: yaml.dump(config, file) text = "john [email protected] [email protected]" assert replace_user_aliases( text ) == "[email protected] [email protected] [email protected]" text = "john [email protected] [email protected]" assert replace_user_aliases( text ) == "[email protected] [email protected] [email protected]" text = "john jane john" assert replace_user_aliases( text) == "[email protected] [email protected]" text = "johnjane" assert replace_user_aliases(text) == "johnjane"
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 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 work_item_query( assignee: str, author: str, label: str, state: str, area: str, iteration: str, work_item_type: str, story_points: str, ): """Build query in wiql. # More on 'work item query language' syntax: # https://docs.microsoft.com/en-us/azure/devops/boards/queries/wiql-syntax?view=azure-devops """ # ensure using user aliases assignee = replace_user_aliases(assignee) author = replace_user_aliases(author) # Get all workitems query = "SELECT [System.Id],[System.Title],[System.AssignedTo]," query += "[System.WorkItemType],[System.State],[System.CreatedDate], [System.State] " query += f"FROM WorkItems WHERE [System.AreaPath] = '{area}' " # Filter on iteration. Note we use UNDER so that user can choose to provide teams path for all sprints. query += f"AND [System.IterationPath] UNDER '{iteration}' " if assignee: query += f"AND [System.AssignedTo] = '{assignee}' " if author: query += f"AND [System.CreatedBy] = '{author}' " if label: for lab in label.split(","): query += f"AND [System.Tags] Contains '{lab.strip()}' " if state: custom_states = get_config("custom_states", {}) if state in custom_states.keys(): if type(custom_states[state]) is not list: custom_states[state] = [custom_states[state]] state_list = ",".join([f"'{x}'" for x in custom_states[state]]) query += f"AND [System.State] IN ({state_list}) " elif state == "open": query += "AND [System.State] NOT IN ('Resolved','Closed','Done','Removed') " elif state == "closed": query += "AND [System.State] IN ('Resolved','Closed','Done') " elif state == "all": query += "AND [System.State] <> 'Removed' " elif state.startswith("'") and state.endswith("'"): query += f"AND [System.State] = {state} " else: raise ValueError( f"Invalid state: '{state}'. State should be:\n" "- one of the doing-cli default states: 'open', 'closed', 'all'\n" "- a custom state defined under 'custom_states' in the .doing-cli.config.yml file\n" "- a state available in this team, between quotes, e.g. \"'Active'\"" ) if work_item_type: validate_work_item_type(work_item_type) query += f"AND [System.WorkItemType] = '{work_item_type}' " if story_points: if story_points == "unassigned": query += "AND [Microsoft.VSTS.Scheduling.StoryPoints] = '' " elif story_points.startswith("<="): story_points = story_points.lstrip("<=") query += f"AND [Microsoft.VSTS.Scheduling.StoryPoints] <= '{story_points}' " elif story_points.startswith(">="): story_points = story_points.lstrip(">=") query += f"AND [Microsoft.VSTS.Scheduling.StoryPoints] >= '{story_points}' " elif story_points.startswith(">"): story_points = story_points.lstrip(">") query += f"AND [Microsoft.VSTS.Scheduling.StoryPoints] > '{story_points}' " elif story_points.startswith("<"): story_points = story_points.lstrip("<") query += f"AND [Microsoft.VSTS.Scheduling.StoryPoints] < '{story_points}' " else: query += f"AND [Microsoft.VSTS.Scheduling.StoryPoints] = '{story_points}' " # Ordering of results query += "ORDER BY [System.CreatedDate] asc" return query
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, ))