Skip to content

Commit

Permalink
Pipelines folder management commands (#788)
Browse files Browse the repository at this point in the history
* Pipeline folder management commands

* Add unit tests for pipeline folder commands

* Add recording tests
  • Loading branch information
atbagga committed Sep 12, 2019
1 parent aedf537 commit 8514800
Show file tree
Hide file tree
Showing 8 changed files with 2,370 additions and 1 deletion.
22 changes: 22 additions & 0 deletions azure-devops/azext_devops/dev/pipelines/_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,3 +374,25 @@ def _transform_pipeline_var_group_variable_row(key, value):
val = val[0:_VALUE_TRUNCATION_LENGTH] + '...'
table_row['Value'] = val
return table_row


def transform_pipelines_folders_table_output(result):
table_output = []
for item in result:
table_output.append(_transform_pipeline_folder_row(item))
return table_output


def transform_pipelines_folder_table_output(result):
table_output = [_transform_pipeline_folder_row(result)]
return table_output


def _transform_pipeline_folder_row(row):
table_row = OrderedDict()
table_row['Path'] = row['path']
val = row['description'] if row['description'] is not None else ''
if len(val) > _VALUE_TRUNCATION_LENGTH:
val = val[0:_VALUE_TRUNCATION_LENGTH] + '...'
table_row['Description'] = val
return table_row
6 changes: 6 additions & 0 deletions azure-devops/azext_devops/dev/pipelines/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ def load_pipelines_help():
long-summary:
"""

helps['pipelines folder'] = """
type: group
short-summary: Manage folders for organizing pipelines.
long-summary:
"""

helps['pipelines agent'] = """
type: group
short-summary: Manage agents.
Expand Down
5 changes: 5 additions & 0 deletions azure-devops/azext_devops/dev/pipelines/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@

_VAR_GROUPS_QUERY_ORDER = ['Asc', 'Desc']

_FOLDERS_QUERY_ORDER = ['Asc', 'Desc', 'None']


# pylint: disable=too-many-statements
def load_build_arguments(self, _):
Expand Down Expand Up @@ -113,3 +115,6 @@ def load_build_arguments(self, _):
context.argument('secret', arg_type=get_three_state_flag())
context.argument('prompt_value', arg_type=get_three_state_flag())
context.argument('allow_override', arg_type=get_three_state_flag())

with self.argument_context('pipelines folder') as context:
context.argument('query_order', **enum_choice_list(_FOLDERS_QUERY_ORDER))
16 changes: 15 additions & 1 deletion azure-devops/azext_devops/dev/pipelines/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
transform_pipelines_variable_groups_table_output,
transform_pipelines_variable_group_table_output,
transform_pipelines_variables_table_output,
transform_pipelines_var_group_variables_table_output)
transform_pipelines_var_group_variables_table_output,
transform_pipelines_folders_table_output,
transform_pipelines_folder_table_output)

buildOps = CliCommandType(
operations_tmpl='azext_devops.dev.pipelines.build#{}',
Expand Down Expand Up @@ -91,6 +93,11 @@
exception_handler=azure_devops_exception_handler
)

pipelineFoldersOps = CliCommandType(
operations_tmpl='azext_devops.dev.pipelines.pipeline_folders#{}',
exception_handler=azure_devops_exception_handler
)


# pylint: disable=too-many-statements
def load_build_commands(self, _):
Expand Down Expand Up @@ -184,3 +191,10 @@ def load_build_commands(self, _):
g.command('list', 'pipeline_variable_list', table_transformer=transform_pipelines_variables_table_output)
g.command('delete', 'pipeline_variable_delete',
confirmation='Are you sure you want to delete this variable?')

with self.command_group('pipelines folder', command_type=pipelineFoldersOps, is_preview=True) as g:
g.command('create', 'pipeline_folder_create', table_transformer=transform_pipelines_folder_table_output)
g.command('delete', 'pipeline_folder_delete', table_transformer=transform_pipelines_folder_table_output,
confirmation='Are you sure you want to delete this folder?')
g.command('list', 'pipeline_folder_list', table_transformer=transform_pipelines_folders_table_output)
g.command('update', 'pipeline_folder_update', table_transformer=transform_pipelines_folder_table_output)
105 changes: 105 additions & 0 deletions azure-devops/azext_devops/dev/pipelines/pipeline_folders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

from knack.log import get_logger
from knack.util import CLIError
from azext_devops.dev.common.services import resolve_instance_and_project, get_build_client

logger = get_logger(__name__)


def pipeline_folder_create(path, description=None, organization=None,
project=None, detect=None):
""" Create a folder.
:param path: Full path of the folder.
:type path: str
:param description: Description of the folder.
:type description: str
:param project: Name or ID of the team project.
:type project: str
:param detect: Automatically detect organization and project. Default is "on".
:type detect: str
"""
organization, project = resolve_instance_and_project(
detect=detect, organization=organization, project=project)
client = get_build_client(organization)
from azext_devops.devops_sdk.v5_0.build.models import Folder
folder = Folder()
folder.description = description
folder.path = path
new_folder = client.create_folder(folder=folder, path=path, project=project)
return new_folder


def pipeline_folder_delete(path, organization=None, project=None, detect=None):
""" Delete a folder.
:param path: Full path of the folder.
:type path: str
:param project: Name or ID of the team project.
:type project: str
:param detect: Automatically detect organization and project. Default is "on".
:type detect: str
"""
organization, project = resolve_instance_and_project(
detect=detect, organization=organization, project=project)
client = get_build_client(organization)
return client.delete_folder(path=path, project=project)


def pipeline_folder_list(path=None, query_order=None, organization=None, project=None, detect=None):
""" List all folders.
:param path: Full path of the folder.
:type path: str
:param query_order: Order in which folders are returned.
:type query_order: str
:param project: Name or ID of the team project.
:type project: str
:param detect: Automatically detect organization and project. Default is "on".
:type detect: str
"""
organization, project = resolve_instance_and_project(
detect=detect, organization=organization, project=project)
client = get_build_client(organization)
if query_order:
if query_order.lower() == 'asc':
query_order = 'folderAscending'
elif query_order.lower() == 'desc':
query_order = 'folderDescending'
return client.get_folders(path=path, query_order=query_order, project=project)


def pipeline_folder_update(path, new_path=None, new_description=None,
organization=None, project=None, detect=None):
""" Update a folder name or description.
:param path: Full path of the folder.
:type path: str
:param new_path: New full path of the folder.
:type new_path: str
:param new_description: New description of the folder.
:type new_description: str
:param project: Name or ID of the team project.
:type project: str
:param detect: Automatically detect organization and project. Default is "on".
:type detect: str
"""
if not new_path and not new_description:
raise CLIError('Either --new-path or --new-description should be specified.')
organization, project = resolve_instance_and_project(
detect=detect, organization=organization, project=project)
client = get_build_client(organization)
folders = client.get_folders(path=path, project=project, query_order='folderAscending')
folder_to_update = None
# find matching folder if present
for folder in folders:
if folder.path.strip('\\') == path.strip('\\'):
folder_to_update = folder
break
if not folder_to_update:
raise CLIError('Cannot find folder with path {}. Update operation failed.'.format(path))
if new_description:
folder_to_update.description = new_description
if new_path:
folder_to_update.path = new_path
return client.update_folder(path=path, folder=folder_to_update, project=project)
104 changes: 104 additions & 0 deletions azure-devops/azext_devops/test/pipelines/test_pipeline_folders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

import unittest

try:
# Attempt to load mock (works on Python 3.3 and above)
from unittest.mock import patch
except ImportError:
# Attempt to load mock (works on Python version below 3.3)
from mock import patch

from azext_devops.dev.common.services import clear_connection_cache
from azext_devops.test.utils.authentication import AuthenticatedTests
from azext_devops.dev.pipelines.pipeline_folders import (pipeline_folder_create,
pipeline_folder_delete,
pipeline_folder_list,
pipeline_folder_update)
from azext_devops.devops_sdk.v5_0.build.build_client import BuildClient
from azext_devops.devops_sdk.v5_0.build.models import Folder


class TestPipelinesFoldersMethods(AuthenticatedTests):

_TEST_DEVOPS_ORGANIZATION = 'https://someorganization.visualstudio.com'
_TEST_DEVOPS_PROJECT = 'MyProject'

def setUp(self):
self.authentication_setup()
self.authenticate()

self.get_client_patcher = patch('azext_devops.devops_sdk.connection.Connection.get_client')
#start the patchers
self.mock_get_client = self.get_client_patcher.start()
# Set return values which will be same across tests
self.mock_get_client.return_value = BuildClient(base_url=self._TEST_DEVOPS_ORGANIZATION)
#clear connection cache before running each test
clear_connection_cache()

def tearDown(self):
patch.stopall()

def test_folder_create(self):
with patch('azext_devops.devops_sdk.v5_0.build.build_client.BuildClient.create_folder') as mock_create_folder:
# Creating folder
new_folder = pipeline_folder_create(path='test', description='test description',
project=self._TEST_DEVOPS_PROJECT, organization=self._TEST_DEVOPS_ORGANIZATION)
folder = Folder()
folder.description = 'test description'
folder.path = 'test'
# assert
mock_create_folder.assert_called_once_with(
folder=folder, path='test', project=self._TEST_DEVOPS_PROJECT)

def test_folder_delete(self):
with patch('azext_devops.devops_sdk.v5_0.build.build_client.BuildClient.delete_folder') as mock_delete_folder:
# deleting folder
pipeline_folder_delete(
path='test', project=self._TEST_DEVOPS_PROJECT, organization=self._TEST_DEVOPS_ORGANIZATION)
# assert
mock_delete_folder.assert_called_once_with(
path='test', project=self._TEST_DEVOPS_PROJECT)

def test_folder_list(self):
with patch('azext_devops.devops_sdk.v5_0.build.build_client.BuildClient.get_folders') as mock_list_folders:
# listing folders
folders = pipeline_folder_list(
path='test', query_order='asc', project=self._TEST_DEVOPS_PROJECT, organization=self._TEST_DEVOPS_ORGANIZATION)
# assert
mock_list_folders.assert_called_once_with(
path='test', query_order='folderAscending', project=self._TEST_DEVOPS_PROJECT)

def test_folder_update(self):
with patch('azext_devops.devops_sdk.v5_0.build.build_client.BuildClient.update_folder') as mock_update_folders:
with patch('azext_devops.devops_sdk.v5_0.build.build_client.BuildClient.get_folders') as mock_list_folders:
folder = Folder()
folder.description ='hello world'
folder.path = 'test'
mock_list_folders.return_value = [folder]

update_folder = Folder()
update_folder.description ='hello world updated'
update_folder.path = 'test123'

updated_folder = pipeline_folder_update(
path='test', new_path='test123', new_description='hello world updated',
project=self._TEST_DEVOPS_PROJECT, organization=self._TEST_DEVOPS_ORGANIZATION)

# assert
mock_list_folders.assert_called_once_with(
path='test', project=self._TEST_DEVOPS_PROJECT, query_order='folderAscending')
mock_update_folders.assert_called_once_with(
folder=mock_list_folders.return_value[0], path='test', project=self._TEST_DEVOPS_PROJECT)
self.assertEqual(mock_list_folders.return_value[0].path, 'test123')
self.assertEqual(mock_list_folders.return_value[0].description, 'hello world updated')


def test_folder_update_with_invalid_input(self):
with self.assertRaises(Exception) as exc:
response = pipeline_folder_update(
path='test', project=self._TEST_DEVOPS_PROJECT, organization=self._TEST_DEVOPS_ORGANIZATION)
self.assertEqual(str(exc.exception),r'Either --new-path or --new-description should be specified.')
2,045 changes: 2,045 additions & 0 deletions tests/recordings/test_pipeline_folders.yaml

Large diffs are not rendered by default.

68 changes: 68 additions & 0 deletions tests/test_pipelineFoldersTest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

import os
import unittest

from azure_devtools.scenario_tests import AllowLargeResponse
from .utilities.helper import DevopsScenarioTest, disable_telemetry, set_authentication, get_test_org_from_env_variable

DEVOPS_CLI_TEST_ORGANIZATION = get_test_org_from_env_variable() or 'Https://dev.azure.com/azuredevopsclitest'

class PipelinesFolderTests(DevopsScenarioTest):
@AllowLargeResponse(size_kb=3072)
@disable_telemetry
@set_authentication
def test_pipeline_folders(self):
random_project_name = self.create_random_name(prefix='pipelinesFolderTest', length=25)
self.cmd('az devops configure --defaults organization=' + DEVOPS_CLI_TEST_ORGANIZATION + ' project=' + random_project_name)
created_project_id = None

try:
create_project_command = 'az devops project create --name ' + random_project_name + ' --output json --detect false'
project_create_output = self.cmd(create_project_command).get_output_in_json()
created_project_id = project_create_output["id"]

# create a pipeline
FOLDER_NAME = '\\TestFolder'
FOLDER_DESCRIPTION = 'Test folder'
folder_create_command = 'az pipelines folder create --path {} --description "{}" \
--detect false --output json'.format(FOLDER_NAME, FOLDER_DESCRIPTION)
folder_create_output = self.cmd(folder_create_command).get_output_in_json()
assert folder_create_output['path'] == FOLDER_NAME
assert folder_create_output['description'] == FOLDER_DESCRIPTION

UPDATED_FOLDER_DESCRIPTION = 'New test folder'
UPDATED_FOLDER_PATH = '\\Testing' + FOLDER_NAME
folder_update_command = 'az pipelines folder update --path {} --new-path "{}" --new-description "{}" \
--detect false --output json'.format(FOLDER_NAME, UPDATED_FOLDER_PATH, UPDATED_FOLDER_DESCRIPTION)
folder_update_output = self.cmd(folder_update_command).get_output_in_json()
assert folder_update_output['path'] == UPDATED_FOLDER_PATH
assert folder_update_output['description'] == UPDATED_FOLDER_DESCRIPTION

folder_list_command = 'az pipelines folder list --detect false --output json --query-order asc'
folder_list_output = self.cmd(folder_list_command).get_output_in_json()
assert len(folder_list_output) == 3 # root folder + testing/test123 nested folders
assert folder_list_output[0]['path'] == '\\' # root folder
assert folder_list_output[0]['description'] == None
assert folder_list_output[1]['path'] == '\\Testing'
assert folder_list_output[1]['description'] == None
assert folder_list_output[2]['path'] == UPDATED_FOLDER_PATH
assert folder_list_output[2]['description'] == UPDATED_FOLDER_DESCRIPTION

folder_delete_command = 'az pipelines folder delete --path "{}" --detect false --output json -y'.format(UPDATED_FOLDER_PATH)
folder_delete_output = self.cmd(folder_delete_command)
# verify deletion
folder_list_output = self.cmd(folder_list_command).get_output_in_json()
assert len(folder_list_output) == 2
assert folder_list_output[0]['path'] == '\\' # root folder
assert folder_list_output[0]['description'] == None
assert folder_list_output[1]['path'] == '\\Testing'
assert folder_list_output[1]['description'] == None

finally:
if created_project_id is not None:
delete_project_command = 'az devops project delete --id ' + created_project_id + ' --output json --detect false -y'
self.cmd(delete_project_command)

0 comments on commit 8514800

Please sign in to comment.