Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Work Item Relation Commands #541

Merged
merged 29 commits into from
Apr 22, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions azure-devops/azext_devops/dev/boards/_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,34 @@
_WORK_ITEM_TITLE_TRUNCATION_LENGTH = 70


def transform_work_item_relation_type_table_output(result):
table_output = []
for item in result:
table_row = OrderedDict()
table_row['Name'] = item['name']
table_row['ReferenceName'] = item['referenceName']
table_row['Enabled'] = item['attributes']['enabled']
table_row['Usage'] = item['attributes']['usage']
table_output.append(table_row)

return table_output


def transform_work_item_relations(result):
if result['relations'] is None:
return []

relations = result['relations']
table_output = []
for item in relations:
table_row = OrderedDict()
table_row['Relation Type'] = item['rel']
table_row['Url'] = item['url']
table_output.append(table_row)

return table_output


def transform_work_items_table_output(result):
table_output = []
for item in result:
Expand Down
6 changes: 6 additions & 0 deletions azure-devops/azext_devops/dev/boards/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@ def load_boards_help():
short-summary: Manage queries.
long-summary:
"""

helps['boards work-item relation'] = """
type: group
short-summary: Manage work item relations.
long-summary:
"""
11 changes: 11 additions & 0 deletions azure-devops/azext_devops/dev/boards/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,14 @@ def load_work_arguments(self, _):
with self.argument_context('boards work-item delete') as context:
context.argument('yes', options_list=['--yes', '-y'], action='store_true',
help='Do not prompt for confirmation.')

with self.argument_context('boards work-item relation') as context:
context.argument('id', help='The ID of the work item')
context.argument('target_id', help='ID(s) of work-items to create relation with. \
Multiple values can be passed comma separated. Example: 1,2 ')

with self.argument_context('boards work-item relation add') as context:
context.argument('relation_type', help='Relation type to create. example: parent, child ')
gauravsaralMs marked this conversation as resolved.
Show resolved Hide resolved

with self.argument_context('boards work-item relation remove') as context:
context.argument('relation_type', help='Relation type to remove. example: parent, child ')
18 changes: 17 additions & 1 deletion azure-devops/azext_devops/dev/boards/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,21 @@
from azure.cli.core.commands import CliCommandType
from azext_devops.dev.common.exception_handler import azure_devops_exception_handler
from ._format import (transform_work_item_table_output,
transform_work_item_query_result_table_output)
transform_work_item_query_result_table_output,
transform_work_item_relation_type_table_output,
transform_work_item_relations)


workItemOps = CliCommandType(
operations_tmpl='azext_devops.dev.boards.work_item#{}',
exception_handler=azure_devops_exception_handler
)

relationsOps = CliCommandType(
operations_tmpl='azext_devops.dev.boards.relations#{}',
exception_handler=azure_devops_exception_handler
)


def load_work_commands(self, _):
with self.command_group('boards', command_type=workItemOps) as g:
Expand All @@ -27,3 +34,12 @@ def load_work_commands(self, _):

# query commands
g.command('query', 'query_work_items', table_transformer=transform_work_item_query_result_table_output)

with self.command_group('boards work-item', command_type=relationsOps) as g:
# relation commands
g.command('relation list-type', 'get_relation_types_show',
table_transformer=transform_work_item_relation_type_table_output)
g.command('relation add', 'add_relation', table_transformer=transform_work_item_relations)
g.command('relation remove', 'remove_relation', table_transformer=transform_work_item_relations,
confirmation='Are you sure you want to remove this relation(s)?')
g.command('relation show', 'show_work_item', table_transformer=transform_work_item_relations)
133 changes: 133 additions & 0 deletions azure-devops/azext_devops/dev/boards/relations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# --------------------------------------------------------------------------------------------
# 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.devops_sdk.v5_0.work_item_tracking.models import JsonPatchOperation, Wiql

from azext_devops.dev.common.services import (get_work_item_tracking_client,
resolve_instance)

logger = get_logger(__name__)


def get_relation_types_show(organization=None, detect=None):
""" List work item relations supported in the organization.
"""
organization = resolve_instance(detect=detect, organization=organization)
client = get_work_item_tracking_client(organization)
return client.get_relation_types()


def add_relation(id, relation_type, target_id, organization=None, detect=None): # pylint: disable=redefined-builtin
""" Add relation(s) to work item.
"""
organization = resolve_instance(detect=detect, organization=organization)
patch_document = []
client = get_work_item_tracking_client(organization)

relation_types_from_service = client.get_relation_types()
relation_type_system_name = get_system_relation_name(relation_types_from_service, relation_type)

target_work_item_ids = target_id.split(',')
work_item_query_clause = []
for target_work_item_id in target_work_item_ids:
work_item_query_clause.append('[System.Id] = {}'.format(target_work_item_id))

wiql_query_format = 'SELECT [System.Id] FROM WorkItems WHERE ({})'
wiql_query_to_get_target_work_items = wiql_query_format.format(' OR '.join(work_item_query_clause))

wiql_object = Wiql()
wiql_object.query = wiql_query_to_get_target_work_items
target_work_items = client.query_by_wiql(wiql=wiql_object).work_items
gauravsaralMs marked this conversation as resolved.
Show resolved Hide resolved

if len(target_work_items) != len(target_work_item_ids):
raise CLIError('Id(s) supplied in --target-ids is not valid')

patch_document = []

for target_work_item in target_work_items:
op = _create_patch_operation('add', '/relations/-', relation_type_system_name, target_work_item.url)
patch_document.append(op)

client.update_work_item(document=patch_document, id=id)
work_item = client.get_work_item(id, expand='All')
work_item = fill_friendly_name_for_relations_in_work_item(relation_types_from_service, work_item)

return work_item


def remove_relation(id, relation_type, target_id, organization=None, detect=None): # pylint: disable=redefined-builtin
""" Remove relation(s) from work item.
"""
organization = resolve_instance(detect=detect, organization=organization)
patch_document = []
client = get_work_item_tracking_client(organization)

relation_types_from_service = client.get_relation_types()
relation_type_system_name = get_system_relation_name(relation_types_from_service, relation_type)
target_work_item_ids = target_id.split(',')

main_work_item = client.get_work_item(id, expand='All')

for target_work_item_id in target_work_item_ids:
target_work_item = client.get_work_item(target_work_item_id, expand='All')
target_work_item_url = target_work_item.url

index = 0
for relation in main_work_item.relations:
if relation.rel == relation_type_system_name and relation.url == target_work_item_url:
po = _create_patch_operation('remove', '/relations/{}'.format(index))
patch_document.append(po)
break
index = index + 1

client.update_work_item(document=patch_document, id=id)
work_item = client.get_work_item(id, expand='All')
work_item = fill_friendly_name_for_relations_in_work_item(relation_types_from_service, work_item)

return work_item


def show_work_item(id, organization=None, detect=None): # pylint: disable=redefined-builtin
""" Get work item, shows relations in table format.
"""
organization = resolve_instance(detect=detect, organization=organization)
client = get_work_item_tracking_client(organization)

work_item = client.get_work_item(id, expand='All')
relation_types_from_service = client.get_relation_types()
work_item = fill_friendly_name_for_relations_in_work_item(relation_types_from_service, work_item)
return work_item


def fill_friendly_name_for_relations_in_work_item(relation_types_from_service, wi):
for relation in wi.relations:
for relation_type_from_service in relation_types_from_service:
if relation_type_from_service.reference_name == relation.rel:
relation.rel = relation_type_from_service.name
return wi


def get_system_relation_name(relation_types_from_service, relation_type):
for relation_type_from_service in relation_types_from_service:
if relation_type_from_service.name.lower() == relation_type.lower():
return relation_type_from_service.reference_name

raise CLIError("--relation-type is not valid. Use \"az boards work-item relation list-type\" " +
"command to list possible relation types in your project")


def _create_patch_operation(op, path, rel=None, url=None):
patch_operation = JsonPatchOperation()
patch_operation.op = op
patch_operation.path = path
if rel is not None and url is not None:
patch_operation.value = {
'rel': rel,
'url': url
}

return patch_operation
2 changes: 1 addition & 1 deletion azure-devops/azext_devops/dev/boards/work_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ def show_work_item(id, open=False, organization=None, detect=None): # pylint: d
organization = resolve_instance(detect=detect, organization=organization)
try:
client = get_work_item_tracking_client(organization)
work_item = client.get_work_item(id)
work_item = client.get_work_item(id, expand='All')
except AzureDevOpsServiceError as ex:
_handle_vsts_service_error(ex)

Expand Down
6 changes: 3 additions & 3 deletions azure-devops/azext_devops/test/boards/test_workitem.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def test_show_work_item_correct_id(self):

# assert
self.mock_validate_token.assert_not_called()
self.mock_get_WI.assert_called_once_with(test_work_item_id)
self.mock_get_WI.assert_called_once_with(test_work_item_id, expand='All')
assert response.id == test_work_item_id


Expand All @@ -77,7 +77,7 @@ def test_show_work_item_correct_id_open_browser(self):
# assert
self.mock_open_browser.assert_called_with(response,self._TEST_DEVOPS_ORGANIZATION)
self.mock_validate_token.assert_not_called()
self.mock_get_WI.assert_called_once_with(test_work_item_id)
self.mock_get_WI.assert_called_once_with(test_work_item_id, expand='All')


def test_show_work_item_raises_exception_invalid_id(self):
Expand All @@ -91,7 +91,7 @@ def test_show_work_item_raises_exception_invalid_id(self):
self.assertEqual(str(exc.exception),r'TF401232: Work item 1000 does not exist, or you do not have permissions to read it.')

#assert
self.mock_get_WI.assert_called_once_with(test_work_item_id)
self.mock_get_WI.assert_called_once_with(test_work_item_id, expand='All')
self.mock_validate_token.assert_not_called()


Expand Down
3,642 changes: 3,642 additions & 0 deletions tests/recordings/test_boards_releations_create_remove.yaml

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions tests/recordings/test_workItemCreateShowUpdateDelete.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ interactions:
X-TFS-Session: [81c994ca-282f-421a-aaf6-a5fa08f08068]
X-VSS-ForceMsaPassThrough: ['true']
method: GET
uri: https://dev.azure.com/AzureDevOpsCliTest/_apis/wit/workItems/139
uri: https://dev.azure.com/AzureDevOpsCliTest/_apis/wit/workItems/139?$expand=All
response:
body: {string: '{"id":139,"rev":1,"fields":{"System.AreaPath":"WorkItemCreateShowUpdateDeleteTests","System.TeamProject":"WorkItemCreateShowUpdateDeleteTests","System.IterationPath":"WorkItemCreateShowUpdateDeleteTests","System.WorkItemType":"Bug","System.State":"New","System.Reason":"New","System.CreatedDate":"2019-03-27T08:40:20.5Z","System.CreatedBy":{"displayName":"Atul
Bagga","url":"https://vssps.dev.azure.com/e/Microsoft/_apis/Identities/86bd48b9-6d39-40d3-b2e0-bf0e2f7f9adc","_links":{"avatar":{"href":"https://dev.azure.com/AzureDevOpsCliTest/_apis/GraphProfile/MemberAvatars/aad.MGVjZTQ5OTktNmIyMy03OTYxLTk2ZTctOWRhMjU0OTMxM2Yy"}},"id":"86bd48b9-6d39-40d3-b2e0-bf0e2f7f9adc","uniqueName":"[email protected]","imageUrl":"https://dev.azure.com/AzureDevOpsCliTest/_apis/GraphProfile/MemberAvatars/aad.MGVjZTQ5OTktNmIyMy03OTYxLTk2ZTctOWRhMjU0OTMxM2Yy","descriptor":"aad.MGVjZTQ5OTktNmIyMy03OTYxLTk2ZTctOWRhMjU0OTMxM2Yy"},"System.ChangedDate":"2019-03-27T08:40:20.5Z","System.ChangedBy":{"displayName":"Atul
Expand Down Expand Up @@ -283,7 +283,7 @@ interactions:
X-TFS-Session: [81c994ca-282f-421a-aaf6-a5fa08f08068]
X-VSS-ForceMsaPassThrough: ['true']
method: GET
uri: https://dev.azure.com/AzureDevOpsCliTest/_apis/wit/workItems/139
uri: https://dev.azure.com/AzureDevOpsCliTest/_apis/wit/workItems/139?$expand=All
response:
body: {string: '{"$id":"1","innerException":null,"message":"TF401232: Work item
139 does not exist, or you do not have permissions to read it.","typeName":"Microsoft.TeamFoundation.WorkItemTracking.Server.WorkItemUnauthorizedAccessException,
Expand Down
82 changes: 82 additions & 0 deletions tests/test_boardsRelationTest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# --------------------------------------------------------------------------------------------
# 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 time
import unittest

from azure.cli.testsdk import ScenarioTest
from azure_devtools.scenario_tests import AllowLargeResponse
from .utilities.helper import 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/gsaralprivate'

class BoardsRelationsTest(ScenarioTest):
def validate_relation_count_on_work_item(self, work_item_set, relation_count_set):
index = 0
for work_item in work_item_set:
show_work_item_command = "az boards work-item show --id {0} --detect off --output json".format(work_item['id'])
work_item = self.cmd(show_work_item_command).get_output_in_json()
if work_item['relations'] == None:
assert relation_count_set[index] == 0
else:
assert len(work_item['relations']) == relation_count_set[index]

index = index + 1

@AllowLargeResponse(size_kb=3072)
@disable_telemetry
@set_authentication
def test_boards_releations_create_remove(self):
random_project_name = self.create_random_name(prefix='WIRel', length=15)
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 off'
project_create_output = self.cmd(create_project_command).get_output_in_json()
created_project_id = project_create_output["id"]

# lets create 4 work items
wi_name = "Bug_{}"
wi_set = []
for number in range(5):
create_wi_command = 'az boards work-item create --project '+ random_project_name +' --title ' \
+ wi_name.format(number) + ' --type Bug --detect off --output json'
created_wit = self.cmd(create_wi_command).get_output_in_json()
wi_set.append(created_wit)

#add bug 1,2,3 as child of 0 (multiple add)
create_relation_command = 'az boards work-item relation add --id {} --detect off --output json'.format(wi_set[0]['id']) \
+ ' --relation-type child --target-id {},{},{}'.format(wi_set[1]['id'], wi_set[2]['id'], wi_set[3]['id'])
create_relation_output = self.cmd(create_relation_command).get_output_in_json()
assert len(create_relation_output['relations']) == 3
self.validate_relation_count_on_work_item(wi_set, [3, 1, 1, 1, 0])

#remove 1,2 as child of 0 (multiple remove)
remove_relation_command = 'az boards work-item relation remove --id {} -y --detect off --output json'.format(wi_set[0]['id']) \
+ ' --relation-type child --target-id {},{}'.format(wi_set[1]['id'], wi_set[2]['id'])
remove_relation_output = self.cmd(remove_relation_command).get_output_in_json()
assert len(remove_relation_output['relations']) == 1
self.validate_relation_count_on_work_item(wi_set, [1, 0, 0, 1, 0])

#add 4 as child of 0 (single add)
create_relation_command = 'az boards work-item relation add --id {} --detect off --output json'.format(wi_set[0]['id']) \
+ ' --relation-type child --target-id {}'.format(wi_set[4]['id'])
create_relation_output = self.cmd(create_relation_command).get_output_in_json()
assert len(create_relation_output['relations']) == 2
self.validate_relation_count_on_work_item(wi_set, [2, 0, 0, 1, 1])

#remove 3 as child of 0 (single remove)
remove_relation_command = 'az boards work-item relation remove -y --id {} --detect off --output json'.format(wi_set[0]['id']) \
+ ' --relation-type child --target-id {}'.format(wi_set[3]['id'])
remove_relation_output = self.cmd(remove_relation_command).get_output_in_json()
assert len(remove_relation_output['relations']) == 1
self.validate_relation_count_on_work_item(wi_set, [1, 0, 0, 0, 1])

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