azure-devops/azext_devops/dev/boards/work_item.py (256 lines of code) (raw):
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------
from __future__ import print_function
import webbrowser
from knack.log import get_logger
from knack.util import CLIError
from azext_devops.devops_sdk.exceptions import AzureDevOpsServiceError
from azext_devops.devops_sdk.v5_0.work_item_tracking.models import JsonPatchOperation, Wiql
from azext_devops.dev.common.arguments import convert_date_string_to_iso8601
from azext_devops.dev.common.identities import (ME, get_current_identity,
resolve_identity,
get_account_from_identity)
from azext_devops.dev.common.services import (get_work_item_tracking_client,
resolve_instance,
resolve_instance_and_project)
from azext_devops.dev.common.uri import uri_quote
logger = get_logger(__name__)
def create_work_item(work_item_type, title, description=None, assigned_to=None, area=None,
iteration=None, reason=None, discussion=None, fields=None, open=False, # pylint: disable=redefined-builtin
organization=None, project=None, detect=None):
r"""Create a work item.
:param work_item_type: Name of the work item type (e.g. Bug).
:type work_item_type: str
:param title: Title of the work item.
:type title: str
:param description: Description of the work item.
:type description: str
:param assigned_to: Name of the person the work item is assigned-to (e.g. fabrikam).
:type assigned_to: str
:param area: Area the work item is assigned to (e.g. Demos)
:type area: str
:param iteration: Iteration path of the work item (e.g. Demos\Iteration 1).
:type iteration: str
:param reason: Reason for the state of the work item.
:type reason: str
:param discussion: Comment to add to a discussion in a work item.
:type discussion: str
:param fields: Space separated "field=value" pairs for custom fields you would like to set.
In case of multiple fields : "field1=value1" "field2=value2".
Refer https://aka.ms/azure-devops-cli-field-api for more details on fields.
:type fields: [str]
:param open: Open the work item in the default web browser.
:type open: bool
:rtype: :class:`<WorkItem> <v5_0.work-item-tracking.models.WorkItem>`
"""
try:
organization, project = resolve_instance_and_project(
detect=detect, organization=organization, project=project, project_required=True)
patch_document = []
if title is not None:
patch_document.append(_create_work_item_field_patch_operation('add', 'System.Title', title))
else:
raise ValueError('--title is a required argument.')
if description is not None:
patch_document.append(_create_work_item_field_patch_operation('add', 'System.Description', description))
if assigned_to is not None:
# 'assigned to' does not take an identity id. Display name works.
assigned_to = assigned_to.strip()
if assigned_to == '':
resolved_assigned_to = ''
else:
resolved_assigned_to = _resolve_identity_as_unique_user_id(assigned_to, organization)
if resolved_assigned_to is not None:
patch_document.append(_create_work_item_field_patch_operation('add', 'System.AssignedTo',
resolved_assigned_to))
if area is not None:
patch_document.append(_create_work_item_field_patch_operation('add', 'System.AreaPath', area))
if iteration is not None:
patch_document.append(_create_work_item_field_patch_operation('add', 'System.IterationPath', iteration))
if reason is not None:
patch_document.append(_create_work_item_field_patch_operation('add', 'System.Reason', reason))
if discussion is not None:
patch_document.append(_create_work_item_field_patch_operation('add', 'System.History', discussion))
if fields is not None and fields:
for field in fields:
kvp = field.split('=', 1)
if len(kvp) == 2:
patch_document.append(_create_work_item_field_patch_operation('add', kvp[0], kvp[1]))
else:
raise ValueError('The --fields argument should consist of space separated "field=value" pairs.')
client = get_work_item_tracking_client(organization)
work_item = client.create_work_item(document=patch_document, project=project, type=work_item_type)
if open:
_open_work_item(work_item, organization)
return work_item
except AzureDevOpsServiceError as ex:
_handle_vsts_service_error(ex)
def update_work_item(id, title=None, description=None, assigned_to=None, state=None, area=None, # pylint: disable=redefined-builtin
iteration=None, reason=None, discussion=None, fields=None, open=False, # pylint: disable=redefined-builtin
organization=None, detect=None):
r"""Update work items.
:param id: The id of the work item to update.
:type id: int
:param title: Title of the work item.
:type title: str
:param description: Description of the work item.
:type description: str
:param assigned_to: Name of the person the work item is assigned-to (e.g. fabrikam).
:type assigned_to: str
:param state: State of the work item (e.g. active).
:type state: str
:param area: Area the work item is assigned to (e.g. Demos).
:type area: str
:param iteration: Iteration path of the work item (e.g. Demos\Iteration 1).
:type iteration: str
:param reason: Reason for the state of the work item.
:type reason: str
:param discussion: Comment to add to a discussion in a work item.
:type discussion: str
:param fields: Space separated "field=value" pairs for custom fields you would like to set.
Refer https://aka.ms/azure-devops-cli-field-api for more details on fields.
:type fields: [str]
:param open: Open the work item in the default web browser.
:type open: bool
:rtype: :class:`<WorkItem> <v5_0.work-item-tracking.models.WorkItem>`
"""
organization = resolve_instance(detect=detect, organization=organization)
patch_document = []
if title is not None:
patch_document.append(_create_work_item_field_patch_operation('add', 'System.Title', title))
if description is not None:
patch_document.append(_create_work_item_field_patch_operation('add', 'System.Description', description))
if assigned_to is not None:
assigned_to = assigned_to.strip()
# 'assigned to' does not take an identity id. Display name works.
if assigned_to == '':
resolved_assigned_to = ''
else:
resolved_assigned_to = _resolve_identity_as_unique_user_id(assigned_to, organization)
if resolved_assigned_to is not None:
patch_document.append(_create_work_item_field_patch_operation('add', 'System.AssignedTo',
resolved_assigned_to))
if state is not None:
patch_document.append(_create_work_item_field_patch_operation('add', 'System.State', state))
if area is not None:
patch_document.append(_create_work_item_field_patch_operation('add', 'System.AreaPath', area))
if iteration is not None:
patch_document.append(_create_work_item_field_patch_operation('add', 'System.IterationPath', iteration))
if reason is not None:
patch_document.append(_create_work_item_field_patch_operation('add', 'System.Reason', reason))
if discussion is not None:
patch_document.append(_create_work_item_field_patch_operation('add', 'System.History', discussion))
if fields is not None and fields:
for field in fields:
kvp = field.split('=', 1)
if len(kvp) == 2:
patch_document.append(_create_work_item_field_patch_operation('add', kvp[0], kvp[1]))
else:
raise ValueError('The --fields argument should consist of space separated "field=value" pairs.')
client = get_work_item_tracking_client(organization)
work_item = client.update_work_item(document=patch_document, id=id)
if open:
_open_work_item(work_item, organization)
return work_item
def delete_work_item(id, # pylint: disable=redefined-builtin
destroy=False, organization=None, project=None, detect=None):
"""Delete a work item.
:param id: Unique id of the work item.
:type id: int
:param destroy: Permanently delete this work item.
:type destroy: bool
:rtype: :class:`<WorkItem> <v5_0.work-item-tracking.models.WorkItemDelete>`
"""
try:
organization, project = resolve_instance_and_project(detect=detect, organization=organization, project=project)
client = get_work_item_tracking_client(organization)
delete_response = client.delete_work_item(id=id, project=project, destroy=destroy)
print('Deleted work item {}'.format(id))
return delete_response
except AzureDevOpsServiceError as ex:
_handle_vsts_service_error(ex)
def _handle_vsts_service_error(ex):
logger.debug(ex, exc_info=True)
if ex.type_key == 'RuleValidationException' and "FieldReferenceName" in ex.custom_properties:
if ex.message is not None:
message = ex.message
if message and message[len(message) - 1] != '.':
message += '.'
name = ex.custom_properties["FieldReferenceName"]
if name is not None:
if name in _SYSTEM_FIELD_ARGS:
message += ' Use the --{} argument to supply this value.'.format(_SYSTEM_FIELD_ARGS[name])
else:
message += ' To specify a value for this field, use the --field argument and set the name of the ' \
+ 'name/value pair to {}.'.format(name)
else:
message = "RuleValidationException for FieldReferenceName: " + ex.custom_properties["FieldReferenceName"]
raise CLIError(ValueError(message))
raise CLIError(ex)
def show_work_item(id, as_of=None, expand='all', fields=None, open=False, organization=None, detect=None): # pylint: disable=redefined-builtin
"""Show details for a work item.
:param id: The ID of the work item
:type id: int
:param as_of: Work item details as of a particular date and time. Provide a date or date time string.
Assumes local time zone. Example: '2019-01-20', '2019-01-20 00:20:00'.
For UTC, append 'UTC' to the date time string, '2019-01-20 00:20:00 UTC'.
:type as_of:string
:param expand: The expand parameters for work item attributes.
:type expand:str
:param fields: Comma-separated list of requested fields. Example:System.Id,System.AreaPath.
Refer https://aka.ms/azure-devops-cli-field-api for more details on fields.
:type fields: str
:param open: Open the work item in the default web browser.
:type open: bool
:rtype: :class:`<WorkItem> <v5_0.work-item-tracking.models.WorkItem>`
"""
organization = resolve_instance(detect=detect, organization=organization)
try:
client = get_work_item_tracking_client(organization)
as_of_iso = None
if as_of:
as_of_iso = convert_date_string_to_iso8601(value=as_of, argument='as-of')
if fields:
fields = fields.split(',')
work_item = client.get_work_item(id, as_of=as_of_iso, fields=fields, expand=expand)
except AzureDevOpsServiceError as ex:
_handle_vsts_service_error(ex)
if open:
_open_work_item(work_item, organization)
return work_item
# pylint: disable=too-many-statements
def query_work_items(wiql=None, id=None, path=None, organization=None, project=None, detect=None): # pylint: disable=redefined-builtin
"""Query for a list of work items. Only supports flat queries.
:param wiql: The query in Work Item Query Language format. Ignored if --id or --path is specified.
:type wiql: str
:param id: The ID of an existing query. Required unless --path or --wiql are specified.
:type id: str
:param path: The path of an existing query. Ignored if --id is specified.
:type path: str
:rtype: :class:`<WorkItem> <v5_0.work-item-tracking.models.WorkItem>`
"""
if wiql is None and path is None and id is None:
raise CLIError("Either the --wiql, --id, or --path argument must be specified.")
organization, project = resolve_instance_and_project(
detect=detect, organization=organization, project=project, project_required=False)
client = get_work_item_tracking_client(organization)
if id is None and path is not None:
if project is None:
raise CLIError("The --project argument must be specified for this query.")
query = client.get_query(project=project, query=path)
id = query.id
if id is not None:
query_result = client.query_by_id(id=id)
else:
wiql_object = Wiql()
wiql_object.query = wiql
query_result = client.query_by_wiql(wiql=wiql_object)
if query_result.work_items:
_last_query_result[_LAST_QUERY_RESULT_KEY] = query_result # store query result for table view
safety_buffer = 100 # a buffer in the max url length to protect going over the limit
remaining_url_length = 2048 - safety_buffer
remaining_url_length -= len(organization)
# following subtracts relative url, the asof parameter and beginning of id and field parameters.
# asof value length will vary, but this should be the longest possible
remaining_url_length -=\
len('/_apis/wit/workItems?ids=&fields=&asOf=2017-11-07T17%3A05%3A34.06699999999999999Z')
fields = []
fields_length_in_url = 0
if query_result.columns:
for field_ref in query_result.columns:
fields.append(field_ref.reference_name)
if fields_length_in_url > 0:
fields_length_in_url += 3 # add 3 for %2C delimiter
fields_length_in_url += len(uri_quote(field_ref.reference_name))
if fields_length_in_url > 800:
logger.info("Not retrieving all fields due to max url length.")
break
remaining_url_length -= fields_length_in_url
max_work_items = 1000
work_items_batch_size = 200
current_batch = []
work_items = []
work_item_url_length = 0
for work_item_ref in query_result.work_items:
if len(work_items) >= max_work_items:
logger.info("Only retrieving the first %s work items.", max_work_items)
break
if work_item_url_length > 0:
work_item_url_length += 3 # add 3 for %2C delimiter
work_item_url_length += len(str(work_item_ref.id))
current_batch.append(work_item_ref.id)
if remaining_url_length - work_item_url_length <= 0 or len(current_batch) == work_items_batch_size:
# url is near max length, go ahead and send first request for details.
# url can go over by an id length because we have a safety buffer
current_batched_items = client.get_work_items(ids=current_batch,
as_of=query_result.as_of,
fields=fields)
for work_item in current_batched_items:
work_items.append(work_item)
current_batch = []
work_item_url_length = 0
if current_batch:
current_batched_items = client.get_work_items(ids=current_batch,
as_of=query_result.as_of,
fields=fields)
for work_item in current_batched_items:
work_items.append(work_item)
# put items in the same order they appeared in the initial query results
work_items = sorted(work_items, key=_get_sort_key_from_last_query_results)
return work_items
return None
def _get_sort_key_from_last_query_results(work_item):
work_items = get_last_query_result().work_items
i = 0
num_items = len(work_items)
while i < num_items:
if work_items[i].id == work_item.id:
return i
i += 1
# following lines should never be reached
raise CLIError("Work Item {} was not found in the original query results.".format(work_item.id))
_last_query_result = {}
_LAST_QUERY_RESULT_KEY = 'value'
def get_last_query_result():
return _last_query_result.get(_LAST_QUERY_RESULT_KEY, None)
def _open_work_item(work_item, organization):
"""Opens the work item in the default browser.
:param work_item: The work item to open.
:type work_item: :class:`<WorkItem> <v5_0.work-item-tracking.models.WorkItem>`
"""
project = work_item.fields['System.TeamProject']
url = organization.rstrip('/') + '/' + uri_quote(project) + '/_workitems?id='\
+ uri_quote(str(work_item.id))
logger.debug('Opening web page: %s', url)
webbrowser.open_new(url=url)
def _create_patch_operation(op, path, value):
patch_operation = JsonPatchOperation()
patch_operation.op = op
patch_operation.path = path
patch_operation.value = value
return patch_operation
def _create_work_item_field_patch_operation(op, field, value):
path = '/fields/{field}'.format(field=field)
return _create_patch_operation(op=op, path=path, value=value)
def _resolve_identity_as_unique_user_id(identity_filter, organization):
"""Takes an identity name, email, alias, or id, and returns the unique_user_id.
"""
if identity_filter.find(' ') > 0 or identity_filter.find('@') > 0:
return identity_filter
if identity_filter.lower() == ME:
identity = get_current_identity(organization)
else:
# For alias
identity = resolve_identity(identity_filter, organization)
if identity is not None:
return get_account_from_identity(identity)
return None
_SYSTEM_FIELD_ARGS = {'System.Title': 'title',
'System.Description': 'description',
'System.AssignedTo': 'assigned-to',
'System.State': 'state',
'System.AreaPath': 'area-path',
'System.IterationPath': 'iteration-path',
'System.Reason': 'reason',
'System.History': 'history'}